You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/10/08 03:41:47 UTC

(solr) branch branch_10x updated (2230d101b1b -> 2e61cd70d8e)

This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a change to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git


    from 2230d101b1b SOLR-18487: Fix NPE For specific Jersey endpoints (#4973)
     new a0a0a26c506 Fix incorrect v2 API examples in the Schema API ref guide (#4942)
     new 7ea60441c4f SOLR-17697 Add opt-in picocli command-line parser (#3254)
     new 2bea0aef965 SOLR-18493: Rename Index & Query section in new admin UI (#4994)
     new aa7bb22c4b8 Ref guide, major-changes-in-solr-9.adoc: 9.11
     new 9ed938a1f7a SOLR-10198: EmbeddedSolrServer StreamingResponseCallback: to SolrJ-native types. (#4965)
     new a9d489d2035 SOLR-13202: Return 400 instead of 500 for join queries missing from/to parameters (#5012)
     new 394a1e28ad7 SOLR-18124: Add tracing spans for UpdateLog replay (#4962)
     new b20293b1573 SOLR-18124: Trace index replication with a span (#4963)
     new b5f148ad7bb SOLR-18504: Merge duplicate OpenAPI path templates (#5024)
     new 9cace892d09 Test fix: TestCollapseQParserPlugin
     new eecc76bebfa CLI: Fix SOLR_CONNECTION fallback and other small improvements (#5054)
     new 2e61cd70d8e SOLR-18508: {!auxIndexJoin} prune join-index column via `[to-min..to-max]` range (#5033)

The 12 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .github/workflows/bin-solr-test.yml                |   8 +-
 AGENTS.md                                          |   1 +
 .../unreleased/PR#5054-picocli-cli-small-fixes.yml |  10 +
 changelog/unreleased/SOLR-10198.yml                |   7 +
 changelog/unreleased/SOLR-13202.yml                |   7 +
 .../SOLR-17697-picocli-experimental-cli.yml        |  12 +
 .../SOLR-18124-updatelog-replay-tracing.yml        |   9 +
 .../SOLR-18493-rename-index-query-new-ui.yml       |   7 +
 .../SOLR-18504-duplicate-path-templates.yml        |  10 +
 ...8508-auxIndexJoin-skip-out-of-range-columns.yml |  11 +
 dev-docs/ref-guide/antora.adoc                     |  53 +++
 gradle/libs.versions.toml                          |   3 +
 solr/api/build.gradle                              |  24 ++
 solr/api/gradle.lockfile                           |   1 +
 .../client/api/endpoint/ClusterFileStoreApis.java  |   4 +-
 .../solr/client/api/endpoint/GetSchemaApi.java     |   5 +-
 solr/benchmark/gradle.lockfile                     |   1 +
 solr/bin/solr                                      |   6 +-
 solr/bin/solr.cmd                                  |  18 +-
 solr/core/build.gradle                             |  21 ++
 solr/core/gradle.lockfile                          | 256 ++++++-------
 .../core/src/java/org/apache/solr/cli/ApiTool.java |   5 +
 .../src/java/org/apache/solr/cli/AssertTool.java   |   5 +
 .../src/java/org/apache/solr/cli/AuthTool.java     | 116 ++++++
 .../src/java/org/apache/solr/cli/CLIUtils.java     |   6 +-
 .../CliDefaultValueProvider.java}                  |  26 +-
 .../src/java/org/apache/solr/cli/ClusterTool.java  |   5 +
 .../org/apache/solr/cli/ConfigSetDownloadTool.java |  95 ++++-
 ...faultToolRuntime.java => ConfigSetOptions.java} |  34 +-
 .../org/apache/solr/cli/ConfigSetUploadTool.java   |  99 ++++--
 .../src/java/org/apache/solr/cli/ConfigTool.java   |   5 +
 .../org/apache/solr/cli/ConnectionOptions.java     |  76 ++++
 .../src/java/org/apache/solr/cli/CreateTool.java   | 250 ++++++++++---
 .../cli/{CLIO.java => CredentialsOptions.java}     |  31 +-
 .../src/java/org/apache/solr/cli/DeleteTool.java   | 116 +++++-
 .../src/java/org/apache/solr/cli/ExportTool.java   |   5 +
 .../java/org/apache/solr/cli/HealthcheckTool.java  |   5 +
 .../apache/solr/cli/{CLIO.java => HelpMixin.java}  |  31 +-
 .../src/java/org/apache/solr/cli/PackageTool.java  |   5 +
 .../src/java/org/apache/solr/cli/PostLogsTool.java |   5 +
 .../src/java/org/apache/solr/cli/PostTool.java     |   5 +
 .../{package-info.java => RecursiveOption.java}    |  16 +-
 .../java/org/apache/solr/cli/RunExampleTool.java   |   5 +
 .../org/apache/solr/cli/SnapshotCreateTool.java    |   5 +
 .../org/apache/solr/cli/SnapshotDeleteTool.java    |   5 +
 .../org/apache/solr/cli/SnapshotDescribeTool.java  |   5 +
 .../org/apache/solr/cli/SnapshotExportTool.java    |   5 +
 .../java/org/apache/solr/cli/SnapshotListTool.java |   5 +
 .../core/src/java/org/apache/solr/cli/SolrCLI.java | 212 ++++++++++-
 .../src/java/org/apache/solr/cli/StartCommand.java | 247 +++++++++++++
 .../src/java/org/apache/solr/cli/StatusTool.java   | 193 ++++++++--
 .../src/java/org/apache/solr/cli/StopCommand.java  |  62 ++++
 .../src/java/org/apache/solr/cli/StreamTool.java   |   5 +
 solr/core/src/java/org/apache/solr/cli/Tool.java   |  26 +-
 .../src/java/org/apache/solr/cli/ToolBase.java     |  66 +++-
 .../java/org/apache/solr/cli/UpdateACLTool.java    |  53 ++-
 .../src/java/org/apache/solr/cli/VersionTool.java  |  36 +-
 .../org/apache/solr/cli/ZkConnectionOptions.java   | 123 +++++++
 .../src/java/org/apache/solr/cli/ZkCpTool.java     |  65 +++-
 .../src/java/org/apache/solr/cli/ZkLsTool.java     |  72 +++-
 .../src/java/org/apache/solr/cli/ZkMkrootTool.java |  74 +++-
 .../src/java/org/apache/solr/cli/ZkMvTool.java     | 100 ++++--
 .../src/java/org/apache/solr/cli/ZkRmTool.java     |  90 ++++-
 solr/core/src/java/org/apache/solr/cli/ZkTool.java |  51 +++
 .../src/java/org/apache/solr/cli/ZkToolHelp.java   |   5 +
 .../client/solrj/embedded/EmbeddedSolrServer.java  |  13 +-
 .../java/org/apache/solr/handler/IndexFetcher.java |   8 +
 .../apache/solr/handler/ReplicationHandler.java    |  24 +-
 .../solr/handler/admin/api/GetSchemaField.java     |  10 +-
 .../org/apache/solr/response/DocsStreamer.java     |  89 +++++
 .../org/apache/solr/search/JoinQParserPlugin.java  |   2 +
 .../search/join/AuxIndexJoinQParserPlugin.java     |   4 +-
 .../search/join/CrossCollectionJoinQParser.java    |   1 +
 .../solr/search/join/ScoreJoinQParserPlugin.java   |  13 +
 .../join/auxindexjoin/JoinIndexScorerSupplier.java |  92 +++--
 .../src/java/org/apache/solr/update/UpdateLog.java |  45 ++-
 solr/core/src/test/org/apache/solr/TestJoin.java   |  15 +
 .../org/apache/solr/cli/AuthToolPicocliTest.java   |  43 +++
 .../src/test/org/apache/solr/cli/AuthToolTest.java |  10 +-
 .../src/test/org/apache/solr/cli/CLIUtilsTest.java |  30 ++
 .../solr/cli/CliDefaultValueProviderTest.java      |  50 +++
 .../apache/solr/cli/CreateToolPicocliTest.java}    |  27 +-
 .../test/org/apache/solr/cli/CreateToolTest.java   |   7 +-
 .../apache/solr/cli/DeleteToolPicocliTest.java}    |  27 +-
 .../test/org/apache/solr/cli/DeleteToolTest.java   |  11 +-
 .../org/apache/solr/cli/SolrCLIPicocliTest.java    | 150 ++++++++
 .../src/test/org/apache/solr/cli/SolrCLITest.java  |  24 ++
 .../apache/solr/cli/StatusToolPicocliTest.java}    |  27 +-
 .../test/org/apache/solr/cli/StatusToolTest.java   |  17 +-
 .../apache/solr/cli/VersionToolPicocliTest.java}   |  26 +-
 .../test/org/apache/solr/cli/VersionToolTest.java  |  18 +-
 .../apache/solr/cli/ZkSubcommandsPicocliTest.java  |  51 +++
 .../org/apache/solr/cli/ZkSubcommandsTest.java     | 128 +++++--
 .../TestEmbeddedSolrServerStreamingTypes.java      | 186 ++++++++++
 .../tagger/EmbeddedSolrNoSerializeTest.java        |   3 +-
 .../solr/search/TestCollapseQParserPlugin.java     |   3 +
 .../search/join/CrossCollectionJoinQueryTest.java  |  23 ++
 .../search/join/TestAuxIndexJoinQParserPlugin.java |  11 +-
 .../search/join/TestGlobalOrdinalsJoinQParser.java |  13 +
 solr/cross-dc-manager/gradle.lockfile              |   1 +
 solr/licenses/picocli-4.7.6.jar.sha1               |   1 +
 ...api-LICENSE-ASL.txt => picocli-LICENSE-ASL.txt} |   0
 solr/licenses/picocli-NOTICE.txt                   |   2 +
 solr/modules/analysis-extras/gradle.lockfile       |   1 +
 solr/modules/clustering/gradle.lockfile            |   1 +
 solr/modules/cross-dc/gradle.lockfile              |   1 +
 solr/modules/cuvs/gradle.lockfile                  |   1 +
 solr/modules/extraction/gradle.lockfile            |   1 +
 solr/modules/gcs-repository/gradle.lockfile        |   1 +
 solr/modules/jwt-auth/gradle.lockfile              |   1 +
 solr/modules/langid/gradle.lockfile                |   1 +
 solr/modules/language-models/gradle.lockfile       |   1 +
 solr/modules/ltr/gradle.lockfile                   |   1 +
 solr/modules/opentelemetry/gradle.lockfile         |   1 +
 solr/modules/s3-repository/gradle.lockfile         |   1 +
 solr/modules/scripting/gradle.lockfile             |   1 +
 solr/modules/sql/gradle.lockfile                   |   1 +
 solr/packaging/build.gradle                        |  79 +++--
 solr/packaging/test/test_create.bats               |   2 +-
 solr/packaging/test/test_status.bats               |   2 +-
 solr/packaging/test/test_zk.bats                   |  15 +-
 solr/server/gradle.lockfile                        |   1 +
 solr/solr-ref-guide/README.adoc                    |   6 +
 solr/solr-ref-guide/build.gradle                   | 394 +++++++++++++++++++++
 solr/solr-ref-guide/gradle.lockfile                | 256 ++++++-------
 .../modules/deployment-guide/deployment-nav.adoc   |  19 +
 .../modules/deployment-guide/pages/cli/index.adoc  |  96 +++++
 .../deployment-guide/pages/cli/solr-auth.adoc      | 124 +++++++
 .../deployment-guide/pages/cli/solr-create.adoc    | 112 ++++++
 .../deployment-guide/pages/cli/solr-delete.adoc    | 101 ++++++
 .../deployment-guide/pages/cli/solr-start.adoc     | 128 +++++++
 .../deployment-guide/pages/cli/solr-status.adoc    | 106 ++++++
 .../deployment-guide/pages/cli/solr-stop.adoc      |  80 +++++
 .../deployment-guide/pages/cli/solr-version.adoc   |  70 ++++
 .../deployment-guide/pages/cli/solr-zk-cp.adoc     | 109 ++++++
 .../pages/cli/solr-zk-downconfig.adoc              | 120 +++++++
 .../deployment-guide/pages/cli/solr-zk-ls.adoc     |  93 +++++
 .../deployment-guide/pages/cli/solr-zk-mkroot.adoc |  93 +++++
 .../deployment-guide/pages/cli/solr-zk-mv.adoc     |  97 +++++
 .../deployment-guide/pages/cli/solr-zk-rm.adoc     |  93 +++++
 .../pages/cli/solr-zk-upconfig.adoc                | 120 +++++++
 .../pages/cli/solr-zk-updateacls.adoc              |  90 +++++
 .../deployment-guide/pages/cli/solr-zk.adoc        |  83 +++++
 .../pages/solr-control-script-reference.adoc       |   9 +
 .../modules/indexing-guide/pages/schema-api.adoc   |  82 +++--
 .../pages/major-changes-in-solr-9.adoc             |  69 ++++
 solr/solrj-jetty/gradle.lockfile                   |   1 +
 solr/solrj-streaming/gradle.lockfile               |   1 +
 solr/solrj-zookeeper/gradle.lockfile               |   1 +
 solr/solrj/gradle.lockfile                         |   1 +
 solr/test-framework/build.gradle                   |   2 +
 solr/test-framework/gradle.lockfile                |   1 +
 solr/webapp/gradle.lockfile                        |   1 +
 .../commonMain/composeResources/values/strings.xml |   2 +-
 .../main/integration/DefaultMainComponent.kt       |   4 +-
 .../apache/solr/ui/views/navigation/MainMenu.kt    |   2 +-
 .../solr/ui/views/navigation/NavigationSideBar.kt  |   6 +-
 157 files changed, 6045 insertions(+), 770 deletions(-)
 create mode 100644 changelog/unreleased/PR#5054-picocli-cli-small-fixes.yml
 create mode 100644 changelog/unreleased/SOLR-10198.yml
 create mode 100644 changelog/unreleased/SOLR-13202.yml
 create mode 100644 changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml
 create mode 100644 changelog/unreleased/SOLR-18124-updatelog-replay-tracing.yml
 create mode 100644 changelog/unreleased/SOLR-18493-rename-index-query-new-ui.yml
 create mode 100644 changelog/unreleased/SOLR-18504-duplicate-path-templates.yml
 create mode 100644 changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml
 copy solr/core/src/java/org/apache/solr/{util/configuration/providers/SysPropSSLCredentialProvider.java => cli/CliDefaultValueProvider.java} (52%)
 copy solr/core/src/java/org/apache/solr/cli/{DefaultToolRuntime.java => ConfigSetOptions.java} (50%)
 create mode 100644 solr/core/src/java/org/apache/solr/cli/ConnectionOptions.java
 copy solr/core/src/java/org/apache/solr/cli/{CLIO.java => CredentialsOptions.java} (65%)
 copy solr/core/src/java/org/apache/solr/cli/{CLIO.java => HelpMixin.java} (63%)
 copy solr/core/src/java/org/apache/solr/cli/{package-info.java => RecursiveOption.java} (70%)
 create mode 100644 solr/core/src/java/org/apache/solr/cli/StartCommand.java
 create mode 100644 solr/core/src/java/org/apache/solr/cli/StopCommand.java
 create mode 100644 solr/core/src/java/org/apache/solr/cli/ZkConnectionOptions.java
 create mode 100644 solr/core/src/java/org/apache/solr/cli/ZkTool.java
 create mode 100644 solr/core/src/test/org/apache/solr/cli/AuthToolPicocliTest.java
 create mode 100644 solr/core/src/test/org/apache/solr/cli/CliDefaultValueProviderTest.java
 copy solr/core/src/{java/org/apache/solr/cli/DefaultToolRuntime.java => test/org/apache/solr/cli/CreateToolPicocliTest.java} (51%)
 copy solr/core/src/{java/org/apache/solr/cli/DefaultToolRuntime.java => test/org/apache/solr/cli/DeleteToolPicocliTest.java} (51%)
 create mode 100644 solr/core/src/test/org/apache/solr/cli/SolrCLIPicocliTest.java
 copy solr/core/src/{java/org/apache/solr/cli/DefaultToolRuntime.java => test/org/apache/solr/cli/StatusToolPicocliTest.java} (52%)
 copy solr/core/src/{java/org/apache/solr/cli/DefaultToolRuntime.java => test/org/apache/solr/cli/VersionToolPicocliTest.java} (52%)
 create mode 100644 solr/core/src/test/org/apache/solr/cli/ZkSubcommandsPicocliTest.java
 create mode 100644 solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerStreamingTypes.java
 create mode 100644 solr/licenses/picocli-4.7.6.jar.sha1
 copy solr/licenses/{apiguardian-api-LICENSE-ASL.txt => picocli-LICENSE-ASL.txt} (100%)
 create mode 100644 solr/licenses/picocli-NOTICE.txt
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-auth.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-delete.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-status.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-stop.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-version.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-cp.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-downconfig.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-ls.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mkroot.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mv.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-rm.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-upconfig.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-updateacls.adoc
 create mode 100644 solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk.adoc


(solr) 12/12: SOLR-18508: {!auxIndexJoin} prune join-index column via `[to-min..to-max]` range (#5033)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 2e61cd70d8e6b1fcec8c19f79c904240800e2f38
Author: Mikhail Khludnev <mk...@apache.org>
AuthorDate: Wed Oct 7 18:04:44 2026 +0300

    SOLR-18508: {!auxIndexJoin} prune join-index column via `[to-min..to-max]` range (#5033)
    
    (cherry picked from commit 2f9c868c24aa3cd60e34df1c103e89d485fcf9f5)
---
 ...8508-auxIndexJoin-skip-out-of-range-columns.yml | 11 +++
 .../join/auxindexjoin/JoinIndexScorerSupplier.java | 92 ++++++++++++++++------
 2 files changed, 81 insertions(+), 22 deletions(-)

diff --git a/changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml b/changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml
new file mode 100644
index 00000000000..78e7acf62c2
--- /dev/null
+++ b/changelog/unreleased/SOLR-18508-auxIndexJoin-skip-out-of-range-columns.yml
@@ -0,0 +1,11 @@
+title: >
+  Faster {!auxIndexJoin} confirmation: join columns whose to-doc range doesn't cover the candidate doc are skipped or dropped without being read.
+
+  Diagnostic logging now reports empty and skipped columns.
+type: changed
+authors:
+  - name: Mikhail Khludnev
+    nick: mkhl
+links:
+  - name: SOLR-18508
+    url: https://issues.apache.org/jira/browse/SOLR-18508
diff --git a/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java b/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
index 9fa09423676..d55bc3710d7 100644
--- a/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
+++ b/solr/core/src/java/org/apache/solr/search/join/auxindexjoin/JoinIndexScorerSupplier.java
@@ -75,9 +75,11 @@ import org.slf4j.LoggerFactory;
  *       lazy one never fires, so a log with no {@code cause=lazy-to-segment} line is the expected
  *       shape.
  *   <li>{@code evt=ctx} -- this context finished setting up: how many (from, to) pairs contributed,
- *       how many the a-priori from-edge check dropped before any column was opened, and how loose
- *       the resulting approximation is. Emitted from {@link #get}, with {@code mode=eager} when
- *       every column is drained up front instead of confirmed lazily.
+ *       how many the a-priori from-edge check dropped before any column was opened ({@code
+ *       cellsEmpty} of them because the pair maps no from doc at all, the rest because the query's
+ *       from matches miss the pair's from-range), and how loose the resulting approximation is.
+ *       Emitted from {@link #get}, with {@code mode=eager} when every column is drained up front
+ *       instead of confirmed lazily.
  *   <li>{@code evt=drain} -- one join column was read through during confirmation, and whether that
  *       read confirmed the doc under test (an early exit) or not.
  *   <li>{@code evt=done} -- confirmation reached a terminal state for this context: every column
@@ -139,6 +141,12 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
   /** pairs the a-priori from-edge check dropped, i.e. columns never opened at all */
   private int leafsDroppedApriori;
 
+  /**
+   * those of {@link #leafsDroppedApriori} whose pair maps no from doc to this to segment at all: a
+   * property of the index layout, not of the query
+   */
+  private int leafsEmpty;
+
   /** calls to {@link LazyConfirmationIterator#matches()} */
   private int confirmCalls;
 
@@ -148,6 +156,11 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
   /** columns read through during confirmation */
   private int leafsDrained;
 
+  /**
+   * columns dropped unread during confirmation, because the doc under test passed their to-range
+   */
+  private int leafsPassed;
+
   /** from-docs walked while draining, i.e. the column reads laziness is trying to avoid */
   private long fromDocsWalked;
 
@@ -199,6 +212,26 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
         } // otherwise we don't know if 0 is real false
       }
 
+      // only cells whose to-range covers the doc can confirm it. One ending below it can't map it
+      // or any later doc, since the approximation only moves forward, so it's dropped unread. One
+      // starting above it stays for the docs to come.
+      final int doc = approximation.docID();
+      List<LeafJoin> covering = new ArrayList<>(leafJoins.size());
+      for (LeafJoin joinTask : new ArrayList<>(leafJoins)) {
+        if (doc > joinTask.toDocEdges()[1]) {
+          JoinIndexScorerSupplier.this.dropJoinLeaf(joinTask);
+          leafsPassed++;
+        } else if (doc >= joinTask.toDocEdges()[0]) {
+          covering.add(joinTask);
+        }
+      }
+      if (covering.isEmpty()) {
+        if (leafJoins.isEmpty()) {
+          converge(doc);
+        }
+        return false;
+      }
+
       IndexSearcher freshSearcher = JoinIndexScorerSupplier.this.joinIndex.acquire();
       try {
         // the result matters here too: a cell whose column the reaper dropped is rebuilt by the
@@ -208,13 +241,12 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
           entry.getKey().bind(entry.getValue());
         }
         assert Objects.equals(JoinIndexScorerSupplier.this.lastSeenJoinSearcher, freshSearcher);
-        for (LeafJoin joinTask : new ArrayList<>(leafJoins)) {
-          if (falseNegToDocsBits == null) {
-            this.shift = approximation.docID();
-            falseNegToDocsBits = new FixedBitSet(lastToDoc + 1 - shift);
-          }
-          DrainOutcome outcome =
-              joinTask.dumpMatchesInto(falseNegToDocsBits, shift, approximation.docID());
+        if (falseNegToDocsBits == null) {
+          this.shift = doc;
+          falseNegToDocsBits = new FixedBitSet(lastToDoc + 1 - shift);
+        }
+        for (LeafJoin joinTask : covering) {
+          DrainOutcome outcome = joinTask.dumpMatchesInto(falseNegToDocsBits, shift, doc);
           fromDocsWalked += joinTask.lastDrainWalked();
           if (outcome == DrainOutcome.CONFIRMED) {
             logDrain(joinTask, true, falseNegToDocsBits);
@@ -231,17 +263,30 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
       } finally {
         JoinIndexScorerSupplier.this.joinIndex.release(freshSearcher);
       }
-      // drop all to masks, got no hit - it means it's a true negative now.
-
-      falsePositiveToDocsBits.clear(shift, lastToDoc + 1);
-      FixedBitSet.orRange(
-          falseNegToDocsBits, 0, falsePositiveToDocsBits, shift, lastToDoc - shift + 1);
+      // every covering cell was drained without a hit, so the doc is a true negative; cells
+      // starting above it may remain, and until they are gone the union is still half-read
+      if (leafJoins.isEmpty()) {
+        converge(doc);
+      }
+      return false;
+    }
 
-      boolean matched = falsePositiveToDocsBits.get(approximation.docID());
-      // every column has now been drained: the half-read union has converged, so from here on
-      // every answer -- true or false -- is a free lookup and there is nothing left to prune
-      logConfirmationDone(matched ? "converged-on-match" : "converged-on-refutation");
-      return matched;
+    /**
+     * Narrows the approximation to the drained union once no cell is left, so from here on every
+     * answer -- true or false -- is a free lookup and there is nothing left to prune. Docs below
+     * {@code doc} are behind the iterator and left as they are.
+     */
+    private void converge(int doc) {
+      if (falseNegToDocsBits == null) {
+        // nothing was ever drained: every cell was passed unread, so nothing ahead can match
+        falsePositiveToDocsBits.clear(doc, lastToDoc + 1);
+      } else {
+        falsePositiveToDocsBits.clear(shift, lastToDoc + 1);
+        FixedBitSet.orRange(
+            falseNegToDocsBits, 0, falsePositiveToDocsBits, shift, lastToDoc - shift + 1);
+      }
+      assert !falsePositiveToDocsBits.get(doc);
+      logConfirmationDone("converged");
     }
 
     @Override
@@ -719,7 +764,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
     JoinIndexUtils.logDiagnostic(
         log,
         "AUXIJOIN evt=ctx ctx={} toSeg={} mode={} toMaxDoc={} cellsCreated={}"
-            + " cellsDroppedApriori={} cellsLive={} buildMs={} approxCard={} approxSpanSum={} approxFrom={} approxTo={}"
+            + " cellsDroppedApriori={} cellsEmpty={} cellsLive={} buildMs={} approxCard={} approxSpanSum={} approxFrom={} approxTo={}"
             + " colToCountSum={}",
         ctxId,
         JoinIndexUtils.segmentName(toContext),
@@ -727,6 +772,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
         toContext.reader().maxDoc(),
         joinLeafsCreated,
         leafsDroppedApriori,
+        leafsEmpty,
         leafJoins.size(),
         joinIndexBuildNanos / 1_000_000L,
         falsePositiveToDocsBits == null ? 0 : falsePositiveToDocsBits.cardinality(),
@@ -749,7 +795,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
     JoinIndexUtils.logDiagnostic(
         log,
         "AUXIJOIN evt=done ctx={} toSeg={} reason={} confirmCalls={} freeHits={} cellsDrained={}"
-            + " cellsLive={} fromDocsWalked={} rebindsAfterReap={} fkLateLoads={} modelsReleased={}"
+            + " cellsPassed={} cellsLive={} fromDocsWalked={} rebindsAfterReap={} fkLateLoads={} modelsReleased={}"
             + " buildMs={}",
         ctxId,
         JoinIndexUtils.segmentName(toContext),
@@ -757,6 +803,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
         confirmCalls,
         confirmFreeHits,
         leafsDrained,
+        leafsPassed,
         leafJoins.size(),
         fromDocsWalked,
         rebindsAfterReap,
@@ -988,6 +1035,7 @@ class JoinIndexScorerSupplier extends ScorerSupplier {
     if (minFromDoc < 0) {
       // {-1, -1} sentinel: this pair maps no from doc to any to doc at all
       leafsDroppedApriori++;
+      leafsEmpty++;
       dropJoinLeaf(cell);
       return false;
     }


(solr) 07/12: SOLR-18124: Add tracing spans for UpdateLog replay (#4962)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 394a1e28ad7a28b1422fe96d7e8354006469fb49
Author: David Smiley <ds...@apache.org>
AuthorDate: Wed Oct 7 14:26:25 2026 -0400

    SOLR-18124: Add tracing spans for UpdateLog replay (#4962)
    
    Co-authored-by: Kamlendra <ka...@gmail.com>
    Co-authored-by: openworld-maker <26...@users.noreply.github.com>
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
    (cherry picked from commit 4aaf68b40fb08b867fe865892d295fc1502afefc)
---
 .../SOLR-18124-updatelog-replay-tracing.yml        |  9 +++++
 .../src/java/org/apache/solr/update/UpdateLog.java | 45 +++++++++++++++++++++-
 2 files changed, 52 insertions(+), 2 deletions(-)

diff --git a/changelog/unreleased/SOLR-18124-updatelog-replay-tracing.yml b/changelog/unreleased/SOLR-18124-updatelog-replay-tracing.yml
new file mode 100644
index 00000000000..655aed1a3a4
--- /dev/null
+++ b/changelog/unreleased/SOLR-18124-updatelog-replay-tracing.yml
@@ -0,0 +1,9 @@
+title: Tracing (spans) for replication, recovery, and updatelog replay.
+type: changed
+authors:
+  - name: Gaurav Singh
+    nick: gsingh-code
+  - name: David Smiley
+links:
+  - name: SOLR-18124
+    url: https://issues.apache.org/jira/browse/SOLR-18124
diff --git a/solr/core/src/java/org/apache/solr/update/UpdateLog.java b/solr/core/src/java/org/apache/solr/update/UpdateLog.java
index 2b0f202a4bd..cfe671f36df 100644
--- a/solr/core/src/java/org/apache/solr/update/UpdateLog.java
+++ b/solr/core/src/java/org/apache/solr/update/UpdateLog.java
@@ -22,6 +22,8 @@ import static org.apache.solr.update.processor.DistributingUpdateProcessorFactor
 import com.carrotsearch.hppc.LongHashSet;
 import com.carrotsearch.hppc.LongSet;
 import io.opentelemetry.api.common.Attributes;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.StatusCode;
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.UncheckedIOException;
@@ -93,6 +95,7 @@ import org.apache.solr.util.RefCounted;
 import org.apache.solr.util.TestInjection;
 import org.apache.solr.util.TimeOut;
 import org.apache.solr.util.plugin.PluginInfoInitialized;
+import org.apache.solr.util.tracing.TraceUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -2129,7 +2132,16 @@ public class UpdateLog implements PluginInfoInitialized, SolrMetricProducer {
       // setting request info will help logging
       SolrRequestInfo.setRequestInfo(new SolrRequestInfo(req, rsp));
 
-      try {
+      final Span span = TraceUtils.getGlobalTracer().spanBuilder("updatelog.replay").startSpan();
+      TraceUtils.ifNotNoop(
+          span,
+          s -> {
+            TraceUtils.setDbInstance(s, req.getCore().getName());
+            s.setAttribute("updatelog.replay.state", state.toString());
+            s.setAttribute("updatelog.replay.logs", translogs.size());
+          });
+      try (var scope = span.makeCurrent()) {
+        assert scope != null; // prevent javac warning about scope being unused
         for (; ; ) {
           TransactionLog translog = translogs.pollFirst();
           if (translog == null) break;
@@ -2143,9 +2155,11 @@ public class UpdateLog implements PluginInfoInitialized, SolrMetricProducer {
           recoveryInfo.errors.incrementAndGet();
           log.error("Replay failed due to exception", e);
         }
+        span.recordException(e);
       } catch (Exception e) {
         recoveryInfo.errors.incrementAndGet();
         log.error("Replay failed due to exception", e);
+        span.recordException(e);
       } finally {
         // change the state while updates are still blocked to prevent races
         state = State.ACTIVE;
@@ -2159,6 +2173,19 @@ public class UpdateLog implements PluginInfoInitialized, SolrMetricProducer {
           log.error("ERROR: didn't get to recover from tlog {}", translog);
           translog.decref();
         }
+
+        TraceUtils.ifNotNoop(
+            span,
+            s -> {
+              s.setAttribute("updatelog.replay.adds", recoveryInfo.adds);
+              s.setAttribute("updatelog.replay.deletes", recoveryInfo.deletes);
+              s.setAttribute("updatelog.replay.deleteByQuery", recoveryInfo.deleteByQuery);
+              s.setAttribute("updatelog.replay.errors", recoveryInfo.errors.get());
+            });
+        if (recoveryInfo.failed || recoveryInfo.errors.get() > 0) {
+          span.setStatus(StatusCode.ERROR);
+        }
+        span.end();
       }
 
       loglog.warn("Log replay finished. recoveryInfo={}", recoveryInfo);
@@ -2169,7 +2196,18 @@ public class UpdateLog implements PluginInfoInitialized, SolrMetricProducer {
     }
 
     public void doReplay(TransactionLog translog) {
-      try {
+      final Span span =
+          TraceUtils.getGlobalTracer().spanBuilder("updatelog.replay.log").startSpan();
+      TraceUtils.ifNotNoop(
+          span,
+          s -> {
+            if (translog.tlog != null) {
+              s.setAttribute("updatelog.replay.logFile", translog.tlog.getFileName().toString());
+            }
+            s.setAttribute("updatelog.replay.logSizeBytes", translog.getLogSize());
+          });
+      try (var scope = span.makeCurrent()) {
+        assert scope != null; // prevent javac warning about scope being unused
         loglog.warn(
             "Starting log replay {}  active={} starting pos={} inSortedOrder={}",
             translog,
@@ -2346,10 +2384,12 @@ public class UpdateLog implements PluginInfoInitialized, SolrMetricProducer {
           } catch (ClassCastException cl) {
             recoveryInfo.errors.incrementAndGet();
             loglog.warn("REPLAY_ERR: Unexpected log entry or corrupt log.  Entry={}", o, cl);
+            span.recordException(cl);
             // would be caused by a corrupt transaction log
           } catch (Exception ex) {
             recoveryInfo.errors.incrementAndGet();
             loglog.warn("REPLAY_ERR: Exception replaying log", ex);
+            span.recordException(ex);
             // something wrong with the request?
           }
           assert TestInjection.injectUpdateLogReplayRandomPause();
@@ -2393,6 +2433,7 @@ public class UpdateLog implements PluginInfoInitialized, SolrMetricProducer {
       } finally {
         if (tlogReader != null) tlogReader.close();
         translog.decref();
+        span.end();
       }
     }
 


(solr) 11/12: CLI: Fix SOLR_CONNECTION fallback and other small improvements (#5054)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit eecc76bebfa645bfa55677af03ba8cde77572cf6
Author: Jan Høydahl <ja...@apache.org>
AuthorDate: Wed Oct 7 16:09:09 2026 +0200

    CLI: Fix SOLR_CONNECTION fallback and other small improvements (#5054)
    
    (cherry picked from commit 5e044a331fd5ff2e4fa1330f0709775f675ad6dc)
---
 AGENTS.md                                          |  1 +
 .../unreleased/PR#5054-picocli-cli-small-fixes.yml | 10 +++++
 .../src/java/org/apache/solr/cli/CLIUtils.java     |  2 +-
 .../apache/solr/cli/CliDefaultValueProvider.java   |  2 +-
 .../src/java/org/apache/solr/cli/CreateTool.java   |  2 +-
 .../src/java/org/apache/solr/cli/StartCommand.java |  6 +--
 .../src/test/org/apache/solr/cli/CLIUtilsTest.java | 30 +++++++++++++
 .../solr/cli/CliDefaultValueProviderTest.java      | 50 ++++++++++++++++++++++
 .../deployment-guide/pages/cli/solr-create.adoc    |  2 +-
 .../deployment-guide/pages/cli/solr-start.adoc     |  8 ++--
 10 files changed, 102 insertions(+), 11 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 47cb9fb4ba6..2232abc0b4e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -39,6 +39,7 @@ While README.md and CONTRIBUTING.md are mainly written for humans, this file is
     - Subclass SolrTestCase, or if SolrCloud is needed then SolrCloudTestCase
     - If SolrTestCase and need to embed Solr, use either EmbeddedSolrServerTestRule (doesn't use HTTP) or SolrJettyTestRule if HTTP/Jetty is relevant to what is being tested.
     - Avoid SolrTestCaseJ4 for new tests
+- System properties set in a test are restored after each test by the base classes (`SystemPropertiesRestoreRule`); do not add `try/finally` + `System.clearProperty` cleanup
 - For BATS shell integration tests in `solr/packaging/test/`:
     - Always use `run <command>` followed by `assert_output --partial "..."` or `refute_output --partial "..."` instead of capturing output into local variables and using `[[ ]]` comparisons
     - Avoid patterns like `local var=$(cmd | grep ...); [[ "$var" == *"..."* ]]` — use `run cmd` + `assert_output`/`refute_output` instead
diff --git a/changelog/unreleased/PR#5054-picocli-cli-small-fixes.yml b/changelog/unreleased/PR#5054-picocli-cli-small-fixes.yml
new file mode 100644
index 00000000000..7e873f5c312
--- /dev/null
+++ b/changelog/unreleased/PR#5054-picocli-cli-small-fixes.yml
@@ -0,0 +1,10 @@
+title: >
+  The SOLR_CONNECTION environment variable (solr.connection system property) is now honored by the
+  bin/solr commands as documented.
+type: fixed
+authors:
+  - name: Jan Høydahl
+    nick: janhoy
+links:
+  - name: PR#5054
+    url: https://github.com/apache/solr/pull/5054
diff --git a/solr/core/src/java/org/apache/solr/cli/CLIUtils.java b/solr/core/src/java/org/apache/solr/cli/CLIUtils.java
index 68d4eedc470..f60d03c3574 100644
--- a/solr/core/src/java/org/apache/solr/cli/CLIUtils.java
+++ b/solr/core/src/java/org/apache/solr/cli/CLIUtils.java
@@ -67,7 +67,7 @@ public final class CLIUtils {
       CommandLine cli) throws IOException {
     String solrConnection =
         getCliOptionOrPropValue(
-            cli, CommonCLIOptions.SOLR_CONNECTION_OPTION, "solr-connection", null);
+            cli, CommonCLIOptions.SOLR_CONNECTION_OPTION, "solr.connection", null);
     if (solrConnection != null && !solrConnection.isBlank()) {
       return CloudSolrClient.CloudSolrClientConnection.parse(solrConnection);
     }
diff --git a/solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java b/solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java
index bb0521c94d8..b449d23ee4e 100644
--- a/solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java
+++ b/solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java
@@ -25,7 +25,7 @@ public class CliDefaultValueProvider implements CommandLine.IDefaultValueProvide
   public String defaultValue(CommandLine.Model.ArgSpec argSpec) throws Exception {
     return switch (argSpec.paramLabel()) {
       case "<zkHost>" -> EnvUtils.getProperty("zkHost");
-      case "<solrConnection>" -> EnvUtils.getProperty("solr-connection");
+      case "<solrConnection>" -> EnvUtils.getProperty("solr.connection");
       case "<solrUrl>" -> EnvUtils.getProperty("solr.url");
       // Must match CLIUtils.getDefaultSolrUrl(), which reads solr.port.listen
       case "<port>" -> EnvUtils.getProperty("solr.port.listen", "8983");
diff --git a/solr/core/src/java/org/apache/solr/cli/CreateTool.java b/solr/core/src/java/org/apache/solr/cli/CreateTool.java
index 25bf09db871..58149b97475 100644
--- a/solr/core/src/java/org/apache/solr/cli/CreateTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/CreateTool.java
@@ -59,7 +59,7 @@ import org.apache.solr.core.ConfigSetService;
       "  bin/solr create -c myCollection",
       "",
       "  # Create a collection with 2 shards and 2 replicas",
-      "  bin/solr create -c myCollection -s 2 -rf 2",
+      "  bin/solr create -c myCollection -sh 2 -rf 2",
       "",
       "  # Create a collection using an existing configset",
       "  bin/solr create -c myCollection -d myconfig"
diff --git a/solr/core/src/java/org/apache/solr/cli/StartCommand.java b/solr/core/src/java/org/apache/solr/cli/StartCommand.java
index 06a747b361f..5af9b497aa8 100644
--- a/solr/core/src/java/org/apache/solr/cli/StartCommand.java
+++ b/solr/core/src/java/org/apache/solr/cli/StartCommand.java
@@ -113,11 +113,11 @@ public class StartCommand implements Callable<Integer> {
   boolean noPrompt;
 
   @CommandLine.Option(
-      names = "--prompt-inputs",
+      names = "--script-inputs",
       description =
           "Don't prompt for input; comma-delimited list of inputs to use when running examples that accept user input",
       paramLabel = "<values>")
-  String promptInputs;
+  String scriptInputs;
 
   @CommandLine.Option(
       names = "--example-dir",
@@ -227,7 +227,7 @@ public class StartCommand implements Callable<Integer> {
           -y, --no-prompt
               Don't prompt for input; accept all defaults when running examples.
 
-          --prompt-inputs <values>
+          --script-inputs <values>
               Don't prompt for input; comma-delimited list of inputs for examples that
               accept user input.
 
diff --git a/solr/core/src/test/org/apache/solr/cli/CLIUtilsTest.java b/solr/core/src/test/org/apache/solr/cli/CLIUtilsTest.java
index 5ec2ddfa5fa..872bcf661c4 100644
--- a/solr/core/src/test/org/apache/solr/cli/CLIUtilsTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/CLIUtilsTest.java
@@ -18,6 +18,9 @@ package org.apache.solr.cli;
 
 import java.net.SocketException;
 import java.net.URISyntaxException;
+import java.util.List;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Options;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.cloud.SolrCloudTestCase;
 import org.apache.solr.common.SolrException;
@@ -141,4 +144,31 @@ public class CLIUtilsTest extends SolrCloudTestCase {
     // "//example.com" are not throwing URISyntaxException. This however is an issue of
     // java.lang.URI, which is very lenient.
   }
+
+  private static Options connectionOptions() {
+    return new Options()
+        .addOption(CommonCLIOptions.SOLR_CONNECTION_OPTION)
+        .addOption(CommonCLIOptions.ZK_HOST_OPTION)
+        .addOption(CommonCLIOptions.SOLR_URL_OPTION);
+  }
+
+  @Test
+  public void testSolrConnectionFromSystemProperty() throws Exception {
+    var cli = new DefaultParser().parse(connectionOptions(), new String[0]);
+    System.setProperty("solr.connection", "zk1:2181,zk2:2181/solr");
+    var connection = CLIUtils.getSolrConnection(cli);
+    assertNotNull("SOLR_CONNECTION / solr.connection should be picked up", connection);
+    assertTrue(connection.isZookeeper());
+    assertEquals(List.of("zk1:2181", "zk2:2181"), connection.quorumItems());
+    assertEquals("/solr", connection.zkChroot());
+  }
+
+  @Test
+  public void testZkHostFromSystemProperty() throws Exception {
+    var cli = new DefaultParser().parse(connectionOptions(), new String[0]);
+    System.setProperty("zkHost", "zk1:2181/solr");
+    var connection = CLIUtils.getSolrConnection(cli);
+    assertNotNull(connection);
+    assertTrue(connection.isZookeeper());
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/CliDefaultValueProviderTest.java b/solr/core/src/test/org/apache/solr/cli/CliDefaultValueProviderTest.java
new file mode 100644
index 00000000000..80ccf0feb9f
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cli/CliDefaultValueProviderTest.java
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import org.apache.solr.SolrTestCase;
+import org.junit.Test;
+import picocli.CommandLine;
+
+public class CliDefaultValueProviderTest extends SolrTestCase {
+
+  private static String defaultFor(String optionName, String paramLabel) throws Exception {
+    var spec = CommandLine.Model.OptionSpec.builder(optionName).paramLabel(paramLabel).build();
+    return new CliDefaultValueProvider().defaultValue(spec);
+  }
+
+  @Test
+  public void testConnectionDefaultsFromSystemProperties() throws Exception {
+    System.setProperty("solr.connection", "zk1:2181/solr");
+    System.setProperty("zkHost", "zk2:2181/solr");
+    System.setProperty("solr.url", "http://solr.local:8983");
+    assertEquals("zk1:2181/solr", defaultFor("--solr-connection", "<solrConnection>"));
+    assertEquals("zk2:2181/solr", defaultFor("--zk-host", "<zkHost>"));
+    assertEquals("http://solr.local:8983", defaultFor("--solr-url", "<solrUrl>"));
+  }
+
+  @Test
+  public void testNoDefaultWhenPropertiesUnset() throws Exception {
+    System.clearProperty("solr.connection");
+    System.clearProperty("zkHost");
+    System.clearProperty("solr.url");
+    assertNull(defaultFor("--solr-connection", "<solrConnection>"));
+    assertNull(defaultFor("--zk-host", "<zkHost>"));
+    assertNull(defaultFor("--solr-url", "<solrUrl>"));
+    assertNull(defaultFor("--other", "<other>"));
+  }
+}
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc
index a359ae8fd2f..a584a9c5565 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc
@@ -103,7 +103,7 @@ Creates a core or collection depending on whether Solr is running in standalone
 
 [%hardbreaks]
   # Create a collection with 2 shards and 2 replicas
-  bin/solr create -c myCollection -s 2 -rf 2
+  bin/solr create -c myCollection -sh 2 -rf 2
 
 [%hardbreaks]
   # Create a collection using an existing configset
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc
index 28970862336..a6ba0f61c2b 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc
@@ -36,7 +36,7 @@ See xref:cli/index.adoc[] to enable this implementation and for its known limita
 bin/solr start [-fhqy] [--force] [--user-managed] [--verbose]
                [--data-home=<dataHome>] [-e=<example>] [--example-dir=<exampleDir>]
                [--host=<host>] [-j=<jettyParams>] [--jvm-opts=<jvmOpts>]
-               [-m=<memory>] [-p=<port>] [--prompt-inputs=<values>]
+               [-m=<memory>] [-p=<port>] [--script-inputs=<values>]
                [--server-dir=<serverDir>] [--solr-home=<solrHome>] [-z=<zkHost>]
 ....
 
@@ -85,12 +85,12 @@ Starts Solr
 *-p*, *--port*=_<port>_::
   Specify the Solr HTTP port; default is 8983. STOP_PORT=($SOLR_PORT-1000), RMI_PORT=($SOLR_PORT+10000)
 
-*--prompt-inputs*=_<values>_::
-  Don't prompt for input; comma-delimited list of inputs to use when running examples that accept user input
-
 *-q*, *--quiet*::
   Set log level to WARN (quiet); default is INFO
 
+*--script-inputs*=_<values>_::
+  Don't prompt for input; comma-delimited list of inputs to use when running examples that accept user input
+
 *--server-dir*=_<serverDir>_::
   Specify the Solr server directory; default is 'server'
 


(solr) 01/12: Fix incorrect v2 API examples in the Schema API ref guide (#4942)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit a0a0a26c5069da0bdda694a081880797f6ced448
Author: Jan Høydahl <ja...@apache.org>
AuthorDate: Tue Oct 6 02:53:56 2026 +0200

    Fix incorrect v2 API examples in the Schema API ref guide (#4942)
    
    Co-authored-by: Eric Pugh <ep...@opensourceconnections.com>
    (cherry picked from commit 97d973814336101e12475558d7419321c743de79)
---
 .../modules/indexing-guide/pages/schema-api.adoc   | 82 ++++++++++++----------
 1 file changed, 43 insertions(+), 39 deletions(-)

diff --git a/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc b/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc
index 9ce7813cdf6..e4ef15edc96 100644
--- a/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc
+++ b/solr/solr-ref-guide/modules/indexing-guide/pages/schema-api.adoc
@@ -122,11 +122,11 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X PUT -H 'Content-type:application/json' --data-binary '
+curl -X PUT 'http://localhost:8983/api/collections/techproducts/schema/fields/sell_by' -H 'Content-type:application/json' --data-binary '
 {
   "type":"pdate",
   "stored":true
-}' http://localhost:8983/api/collections/techproducts/schema/fields/sell_by
+}'
 ----
 ====
 ======
@@ -156,7 +156,7 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X DELETE "http://localhost:8983/api/collections/techproducts/schema/fields/sell_by"
+curl -X DELETE 'http://localhost:8983/api/collections/techproducts/schema/fields/sell_by'
 ----
 ====
 ======
@@ -193,10 +193,10 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X PUT -H 'Content-type:application/json' --data-binary '{
+curl -X PUT 'http://localhost:8983/api/collections/techproducts/schema/fields/sell_by' -H 'Content-type:application/json' --data-binary '{
    "type":"date",
    "stored":false
-}' http://localhost:8983/api/collections/techproducts/schema/fields/sell_by
+}'
 ----
 ====
 ======
@@ -231,10 +231,10 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X PUT -H 'Content-type:application/json' --data-binary '{
+curl -X PUT 'http://localhost:8983/api/collections/techproducts/schema/dynamicfields/*_s' -H 'Content-type:application/json' --data-binary '{
    "type":"string",
    "stored":true
-}' "http://localhost:8983/api/collections/techproducts/schema/dynamicfields/*_s"
+}'
 ----
 ====
 ======
@@ -264,7 +264,7 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X DELETE "http://localhost:8983/api/collections/techproducts/schema/dynamicfields/*_s"
+curl -X DELETE 'http://localhost:8983/api/collections/techproducts/schema/dynamicfields/*_s'
 ----
 ====
 ======
@@ -301,10 +301,10 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X PUT -H 'Content-type:application/json' --data-binary '{
+curl -X PUT 'http://localhost:8983/api/collections/techproducts/schema/dynamicfields/*_s' -H 'Content-type:application/json' --data-binary '{
    "type": "text_general",
    "stored": false
-}' "http://localhost:8983/api/collections/techproducts/schema/dynamicfields/*_s"
+}'
 ----
 ====
 ======
@@ -376,7 +376,7 @@ V2 API with Two Analyzers::
 To define two analyzers with the V2 API, we just use a different endpoint:
 [source,bash]
 ----
-curl -X PUT -H 'Content-type:application/json' --data-binary '{
+curl -X PUT 'http://localhost:8983/api/collections/techproducts/schema/fieldtypes/myNewTextField' -H 'Content-type:application/json' --data-binary '{
    "class":"solr.TextField",
    "indexAnalyzer":{
       "tokenizer":{
@@ -385,7 +385,7 @@ curl -X PUT -H 'Content-type:application/json' --data-binary '{
    "queryAnalyzer":{
       "tokenizer":{
          "name":"keyword" }}
-}' http://localhost:8983/api/collections/techproducts/schema/fieldtypes/myNewTextField
+}'
 ----
 ====
 ======
@@ -415,7 +415,7 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X DELETE "http://localhost:8983/api/collections/techproducts/schema/fieldtypes/myNewTxtField"
+curl -X DELETE 'http://localhost:8983/api/collections/techproducts/schema/fieldtypes/myNewTxtField'
 ----
 ====
 ======
@@ -456,13 +456,13 @@ V2 API::
 ====
 [source,bash]
 ----
-curl -X PUT -H 'Content-type:application/json' --data-binary '{
+curl -X PUT 'http://localhost:8983/api/collections/techproducts/schema/fieldtypes/myNewTxtField' -H 'Content-type:application/json' --data-binary '{
    "class":"solr.TextField",
    "positionIncrementGap":"100",
    "analyzer":{
       "tokenizer":{
          "name":"standard" }}
-}' http://localhost:8983/api/collections/techproducts/schema/fieldtypes/myNewTxtField
+}'
 ----
 ====
 ======
@@ -490,6 +490,7 @@ s|Required |Default: none
 |===
 +
 A field or an array of fields to which the source field will be copied.
+In the v2 API this attribute is named `destinations`; `dest` is still accepted as an alias.
 
 `maxChars`::
 +
@@ -523,11 +524,13 @@ V2 API::
 ====
 [source,bash]
 ----
-curl 'http://localhost:8983/api/collections/techproducts/schema' -H 'Content-type:application/json' --data-binary '{
-  "add-copy-field":{
-     "source":"shelf",
-     "dest":[ "location", "catchall" ]}
-}'
+curl 'http://localhost:8983/api/collections/techproducts/schema/bulk' -H 'Content-type:application/json' -d '[
+  {
+    "operationType":"add-copy-field",
+    "source":"shelf",
+    "destinations":[ "location", "catchall" ]
+  }
+]'
 ----
 ====
 ======
@@ -559,9 +562,9 @@ V2 API::
 ====
 [source,bash]
 ----
-curl 'http://localhost:8983/api/collections/techproducts/schema' -H 'Content-type:application/json' --data-binary '{
-  "delete-copy-field":{ "source":"shelf", "dest":"location" }
-}'
+curl 'http://localhost:8983/api/collections/techproducts/schema/bulk' -H 'Content-type:application/json' --data-binary '[
+  { "operationType":"delete-copy-field", "source":"shelf", "destinations":[ "location" ] }
+]'
 ----
 ====
 ======
@@ -636,27 +639,28 @@ curl 'http://localhost:8983/solr/techproducts/schema' -H 'Content-type:applicati
 }'
 ----
 
-Bulk schema operations are also supported in Solr's v2 API, using a slightly different syntax.
+Bulk schema operations are also supported in Solr's v2 API, using a different endpoint and syntax.
+The v2 bulk endpoint is `POST /api/collections/_collection_/schema/bulk`; the `/schema` path itself only supports `GET` in v2.
 Individual operations are specified as elements within a list, with the operation name (e.g. `add-field`, `delete-field-type`) specified as a `"operationType"` property on each operation object:
 
 [source,bash]
 ----
-curl 'http://localhost:8983/solr/techproducts/schema/bulk' -H 'Content-type:application/json' --data-binary '
+curl 'http://localhost:8983/api/collections/techproducts/schema/bulk' -H 'Content-type:application/json' --data-binary '
   [
     {
-      "operationType": "add-field"
+      "operationType": "add-field",
       "name": "shelf",
       "type": "myNewTxtField",
-      "stored": true 
+      "stored": true
     },
     {
-      "operationType": "add-field"
+      "operationType": "add-field",
       "name": "location",
       "type": "myNewTxtField",
-      "stored": true 
+      "stored": true
     },
     {
-      "operationType": "add-copy-field"
+      "operationType": "add-copy-field",
       "source": "shelf",
       "destinations": ["location", "catchall"]
     }
@@ -729,7 +733,7 @@ Get the entire schema in JSON.
 
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema
+curl 'http://localhost:8983/solr/techproducts/schema'
 ----
 
 [source,json]
@@ -793,7 +797,7 @@ Get the entire schema in XML.
 
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema?wt=xml
+curl 'http://localhost:8983/solr/techproducts/schema?wt=xml'
 ----
 
 [source,xml]
@@ -847,7 +851,7 @@ Get the entire schema in "schema.xml" format.
 
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema?wt=schema.xml
+curl 'http://localhost:8983/solr/techproducts/schema?wt=schema.xml'
 ----
 
 [source,xml]
@@ -1212,7 +1216,7 @@ V1 API::
 ====
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema/fieldtypes
+curl 'http://localhost:8983/solr/techproducts/schema/fieldtypes'
 ----
 ====
 
@@ -1335,7 +1339,7 @@ V1 API::
 ====
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema/copyfields
+curl 'http://localhost:8983/solr/techproducts/schema/copyfields'
 ----
 ====
 
@@ -1425,7 +1429,7 @@ V1 API::
 ====
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema/name
+curl 'http://localhost:8983/solr/techproducts/schema/name'
 ----
 ====
 
@@ -1495,7 +1499,7 @@ V1 API::
 ====
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema/version
+curl 'http://localhost:8983/solr/techproducts/schema/version'
 ----
 ====
 
@@ -1564,7 +1568,7 @@ V1 API::
 ====
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema/uniquekey
+curl 'http://localhost:8983/solr/techproducts/schema/uniquekey'
 ----
 ====
 
@@ -1633,7 +1637,7 @@ V1 API::
 ====
 [source,bash]
 ----
-curl http://localhost:8983/solr/techproducts/schema/similarity
+curl 'http://localhost:8983/solr/techproducts/schema/similarity'
 ----
 ====
 


(solr) 05/12: SOLR-10198: EmbeddedSolrServer StreamingResponseCallback: to SolrJ-native types. (#4965)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 9ed938a1f7a86fcd405408db2635e9d4ebbb1eb0
Author: Nick <ni...@gmail.com>
AuthorDate: Wed Oct 7 07:19:32 2026 -0600

    SOLR-10198: EmbeddedSolrServer StreamingResponseCallback: to SolrJ-native types. (#4965)
    
    -- instead of Lucene types.  Thus a user's callback implementation can be standard whether it's using EmbeddedSolrServer or a normal SolrClient.
    
    (cherry picked from commit e97c2a0884cbd68790fd7e7036fbb7800db27856)
---
 changelog/unreleased/SOLR-10198.yml                |   7 +
 .../client/solrj/embedded/EmbeddedSolrServer.java  |  13 +-
 .../org/apache/solr/response/DocsStreamer.java     |  89 ++++++++++
 .../TestEmbeddedSolrServerStreamingTypes.java      | 186 +++++++++++++++++++++
 .../tagger/EmbeddedSolrNoSerializeTest.java        |   3 +-
 5 files changed, 293 insertions(+), 5 deletions(-)

diff --git a/changelog/unreleased/SOLR-10198.yml b/changelog/unreleased/SOLR-10198.yml
new file mode 100644
index 00000000000..f10ba33fee6
--- /dev/null
+++ b/changelog/unreleased/SOLR-10198.yml
@@ -0,0 +1,7 @@
+title: "EmbeddedSolrServer StreamingResponseCallback: Normalize Lucene stored fields to SolrJ-native types."
+type: fixed
+authors:
+  - name: Nick Shanin
+links:
+  - name: SOLR-10198
+    url: https://issues.apache.org/jira/browse/SOLR-10198
diff --git a/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java b/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
index f9489beb73a..e43bbfb5cdc 100644
--- a/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
+++ b/solr/core/src/java/org/apache/solr/client/solrj/embedded/EmbeddedSolrServer.java
@@ -56,9 +56,11 @@ import org.apache.solr.core.SolrCore;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.request.SolrRequestHandler;
 import org.apache.solr.request.SolrRequestInfo;
+import org.apache.solr.response.DocsStreamer;
 import org.apache.solr.response.JavaBinResponseWriter;
 import org.apache.solr.response.ResultContext;
 import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.schema.IndexSchema;
 import org.apache.solr.servlet.SolrRequestParsers;
 
 /**
@@ -292,7 +294,7 @@ public class EmbeddedSolrServer extends SolrClient {
           };
 
       // invoke callbacks, and writes the rest to byteBuffer
-      try (var javaBinCodec = createJavaBinCodec(callback, resolver)) {
+      try (var javaBinCodec = createJavaBinCodec(callback, resolver, req.getSchema())) {
         javaBinCodec.setWritableDocFields(resolver).marshal(rsp.getValues(), byteBuffer);
       }
     }
@@ -348,12 +350,17 @@ public class EmbeddedSolrServer extends SolrClient {
   }
 
   private JavaBinCodec createJavaBinCodec(
-      final StreamingResponseCallback callback, final JavaBinResponseWriter.Resolver resolver) {
+      final StreamingResponseCallback callback,
+      final JavaBinResponseWriter.Resolver resolver,
+      final IndexSchema schema) {
     return new JavaBinCodec(resolver) {
 
       @Override
       public void writeSolrDocument(SolrDocument doc) {
-        callback.streamSolrDocument(doc);
+        // The callback receives the document itself, so it bypasses the conversion the
+        // Resolver applies when a document is serialized. Convert stored fields here so
+        // streamed documents carry the same native types as query() results.
+        callback.streamSolrDocument(DocsStreamer.externalizeStoredValues(doc, schema));
         // super.writeSolrDocument( doc, fields );
       }
 
diff --git a/solr/core/src/java/org/apache/solr/response/DocsStreamer.java b/solr/core/src/java/org/apache/solr/response/DocsStreamer.java
index 1f7a2e5ac28..7b33cf5800c 100644
--- a/solr/core/src/java/org/apache/solr/response/DocsStreamer.java
+++ b/solr/core/src/java/org/apache/solr/response/DocsStreamer.java
@@ -19,10 +19,14 @@ package org.apache.solr.response;
 import static org.apache.solr.schema.FieldType.ExternalizeStoredValuesAsObjects;
 
 import java.io.IOException;
+import java.lang.invoke.MethodHandles;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexableField;
@@ -54,9 +58,14 @@ import org.apache.solr.search.DocList;
 import org.apache.solr.search.ReturnFields;
 import org.apache.solr.search.SolrDocumentFetcher;
 import org.apache.solr.search.SolrReturnFields;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /** This streams SolrDocuments from a DocList and applies transformer */
 public class DocsStreamer implements Iterator<SolrDocument> {
+  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
+  private static final Object FAILED_STORED_VALUE = new Object();
+
   /**
    * A hardcoded list of known Solr field types that will be trusted to control their own conversion
    * of stored field values into external Objects (via {@link FieldType#toObject}) when returning
@@ -186,6 +195,86 @@ public class DocsStreamer implements Iterator<SolrDocument> {
   public void remove() { // do nothing
   }
 
+  /**
+   * Replace Lucene {@link IndexableField} values on a {@link SolrDocument} (and nested / child
+   * documents) with the SolrJ-native objects that clients see after JavaBin deserialization. The
+   * serialized JavaBin path already converts stored values through JavaBinResponseWriter.Resolver;
+   * this exists for the EmbeddedSolrServer streaming path, whose codec hands documents straight to
+   * the callback, so {@code queryAndStreamResponse} matches {@code query} / {@code HttpSolrClient}.
+   *
+   * <p>A stored value that cannot be converted is logged and omitted; conversion continues for the
+   * remaining values and fields.
+   *
+   * <p>Do not call this from {@link #convertLuceneDocToSolrDoc}; JSON/XML writers and some
+   * transformers still expect stored fields as {@link IndexableField}.
+   *
+   * @see #getValue(SchemaField, IndexableField)
+   */
+  public static SolrDocument externalizeStoredValues(SolrDocument doc, IndexSchema schema) {
+    if (doc == null || schema == null) {
+      return doc;
+    }
+    List<String> failedFields = null;
+    for (Iterator<Map.Entry<String, Object>> it = doc.iterator(); it.hasNext(); ) {
+      Map.Entry<String, Object> entry = it.next();
+      Object val = entry.getValue();
+      Object converted = externalizeValue(val, schema);
+      if (FAILED_STORED_VALUE.equals(converted)) {
+        // The document's entry iterator does not support remove(), so collect the
+        // failed fields and remove them from the document after the loop instead.
+        if (failedFields == null) {
+          failedFields = new ArrayList<>();
+        }
+        failedFields.add(entry.getKey());
+      } else if (!Objects.equals(converted, val)) {
+        entry.setValue(converted);
+      }
+    }
+    if (failedFields != null) {
+      for (String failedField : failedFields) {
+        doc.remove(failedField);
+      }
+    }
+    List<SolrDocument> children = doc.getChildDocuments();
+    if (children != null) {
+      for (SolrDocument child : children) {
+        externalizeStoredValues(child, schema);
+      }
+    }
+    return doc;
+  }
+
+  private static Object externalizeValue(Object val, IndexSchema schema) {
+    if (val instanceof IndexableField f) {
+      try {
+        return getValue(schema.getFieldOrNull(f.name()), f);
+      } catch (Exception | AssertionError e) {
+        // AssertionError: point field types (IntPointField, DatePointField, ...) throw it
+        // from toObject when a stored value predates a change to a point type.
+        log.warn("Error reading a field : {}", f, e);
+        return FAILED_STORED_VALUE;
+      }
+    }
+    if (val instanceof SolrDocument nested) {
+      return externalizeStoredValues(nested, schema);
+    }
+    if (val instanceof Collection<?> coll) {
+      List<Object> out = new ArrayList<>(coll.size());
+      boolean changed = false;
+      for (Object item : coll) {
+        Object converted = externalizeValue(item, schema);
+        if (FAILED_STORED_VALUE.equals(converted)) {
+          changed = true;
+          continue;
+        }
+        changed |= !Objects.equals(converted, item);
+        out.add(converted);
+      }
+      return changed ? (out.isEmpty() ? FAILED_STORED_VALUE : out) : val;
+    }
+    return val;
+  }
+
   public static Object getValue(SchemaField sf, IndexableField f) {
     FieldType ft = null;
     if (sf != null) {
diff --git a/solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerStreamingTypes.java b/solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerStreamingTypes.java
new file mode 100644
index 00000000000..a77d1cd01fd
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/client/solrj/embedded/TestEmbeddedSolrServerStreamingTypes.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.client.solrj.embedded;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.StoredField;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexableField;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.client.solrj.response.StreamingResponseCallback;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.core.SolrCore;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.util.EmbeddedSolrServerTestRule;
+import org.apache.solr.util.RefCounted;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/**
+ * EmbeddedSolrServer {@code queryAndStreamResponse} must return the same native field types as
+ * {@code query} / HttpSolrClient, not Lucene {@link IndexableField} instances.
+ */
+public class TestEmbeddedSolrServerStreamingTypes extends SolrTestCase {
+
+  @ClassRule
+  public static final EmbeddedSolrServerTestRule solrTestRule = new EmbeddedSolrServerTestRule();
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    solrTestRule.startSolr(SolrTestCaseJ4.TEST_HOME());
+    SolrTestCaseJ4.newRandomConfig();
+    solrTestRule.newCollection().withConfigSet(SolrTestCaseJ4.TEST_COLL1_CONF()).create();
+  }
+
+  @Test
+  public void testQueryAndStreamResponseReturnsNativeFieldTypes() throws Exception {
+    EmbeddedSolrServer server = solrTestRule.getSolrClient("collection1");
+    server.deleteByQuery("*:*");
+    // city_s1 is *_s1: stored string, multiValued=false. schema.xml is version 1.0, so
+    // fields without multiValued="false" (including name) default to multiValued and
+    // query() returns a one-element list. foo_i_p is pint so the native type is Integer.
+    // name is multiValued: streaming must convert each element (Collection branch).
+    SolrInputDocument doc = new SolrInputDocument();
+    doc.addField("id", "1");
+    doc.addField("foo_i_p", 42);
+    doc.addField("city_s1", "Boston");
+    doc.addField("name", "Alice");
+    doc.addField("name", "Bob");
+    server.add(doc);
+    server.commit();
+
+    SolrQuery q = new SolrQuery("*:*");
+    q.setFields("id", "foo_i_p", "city_s1", "name");
+
+    QueryResponse rsp = server.query(q);
+    SolrDocument queried = rsp.getResults().get(0);
+    Object queriedId = queried.getFieldValue("id");
+    Object queriedInt = queried.getFieldValue("foo_i_p");
+    Object queriedName = queried.getFieldValue("city_s1");
+    assertEquals("1", queriedId);
+    assertEquals(Integer.valueOf(42), queriedInt);
+    assertEquals("Boston", queriedName);
+    assertFalse("query() must not leak Lucene stored fields", queriedInt instanceof IndexableField);
+
+    AtomicReference<SolrDocument> streamed = new AtomicReference<>();
+    server.queryAndStreamResponse(
+        q,
+        new StreamingResponseCallback() {
+          @Override
+          public void streamSolrDocument(SolrDocument doc) {
+            streamed.set(doc);
+          }
+
+          @Override
+          public void streamDocListInfo(long numFound, long start, Float maxScore) {
+            assertEquals(1, numFound);
+          }
+        });
+
+    SolrDocument streamedDoc = streamed.get();
+    assertNotNull(streamedDoc);
+    Object streamedId = streamedDoc.getFieldValue("id");
+    Object streamedInt = streamedDoc.getFieldValue("foo_i_p");
+    Object streamedName = streamedDoc.getFieldValue("city_s1");
+    assertFalse(
+        "queryAndStreamResponse must not leak Lucene stored fields",
+        streamedInt instanceof IndexableField);
+    assertEquals(queriedId.getClass(), streamedId.getClass());
+    assertEquals(queriedInt.getClass(), streamedInt.getClass());
+    assertEquals(queriedName.getClass(), streamedName.getClass());
+    assertEquals(queriedId, streamedId);
+    assertEquals(queriedInt, streamedInt);
+    assertEquals(queriedName, streamedName);
+    assertEquals(Integer.class, streamedInt.getClass());
+    assertEquals(String.class, streamedName.getClass());
+    // multi-valued field: both paths return a list of native Strings
+    Object queriedNames = queried.getFieldValue("name");
+    Object streamedNames = streamedDoc.getFieldValue("name");
+    assertTrue(queriedNames instanceof List);
+    assertTrue(streamedNames instanceof List);
+    assertEquals(queriedNames, streamedNames);
+    assertEquals(Arrays.asList("Alice", "Bob"), streamedNames);
+  }
+
+  @Test
+  public void testStreamingOmitsValueWhoseConversionThrowsAssertionError() throws Exception {
+    EmbeddedSolrServer server = solrTestRule.getSolrClient("collection1");
+    server.deleteByQuery("*:*");
+    server.commit();
+
+    // Write a Lucene document directly, bypassing the update pipeline: count_i_p is a
+    // pint field now, but the stored value written here is a string, as if it had been
+    // indexed before the field's type changed to pint. (A pint field the other test in
+    // this class does not use, so the raw field cannot clash with its field metadata.)
+    // IntPointField.toObject throws AssertionError for such a value (it has no numeric
+    // value); that must not fail the whole streamed response. The value is logged and
+    // omitted instead. The other fields are created through the schema so their Lucene
+    // field shapes match documents the update pipeline writes.
+    Document luceneDoc = new Document();
+    luceneDoc.add(new StoredField("count_i_p", "not-a-number"));
+    try (SolrCore core = solrTestRule.getCoreContainer().getCore("collection1")) {
+      IndexSchema schema = core.getLatestSchema();
+      for (IndexableField f : schema.getField("id").createFields("bad1")) {
+        luceneDoc.add(f);
+      }
+      for (IndexableField f : schema.getField("city_s1").createFields("Boston")) {
+        luceneDoc.add(f);
+      }
+      RefCounted<IndexWriter> iwRef = core.getSolrCoreState().getIndexWriter(core);
+      try {
+        iwRef.get().addDocument(luceneDoc);
+      } finally {
+        iwRef.decref();
+      }
+    }
+    server.commit();
+
+    SolrQuery q = new SolrQuery("*:*");
+    q.setFields("id", "count_i_p", "city_s1");
+
+    AtomicReference<SolrDocument> streamed = new AtomicReference<>();
+    server.queryAndStreamResponse(
+        q,
+        new StreamingResponseCallback() {
+          @Override
+          public void streamSolrDocument(SolrDocument doc) {
+            streamed.set(doc);
+          }
+
+          @Override
+          public void streamDocListInfo(long numFound, long start, Float maxScore) {
+            assertEquals(1, numFound);
+          }
+        });
+
+    SolrDocument streamedDoc = streamed.get();
+    assertNotNull("the document must still be streamed", streamedDoc);
+    assertEquals("bad1", streamedDoc.getFieldValue("id"));
+    assertEquals("Boston", streamedDoc.getFieldValue("city_s1"));
+    assertFalse(
+        "the unconvertible value must be omitted, not streamed",
+        streamedDoc.containsKey("count_i_p"));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/handler/tagger/EmbeddedSolrNoSerializeTest.java b/solr/core/src/test/org/apache/solr/handler/tagger/EmbeddedSolrNoSerializeTest.java
index ca70f22b066..4f67825f1c6 100644
--- a/solr/core/src/test/org/apache/solr/handler/tagger/EmbeddedSolrNoSerializeTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/tagger/EmbeddedSolrNoSerializeTest.java
@@ -27,7 +27,6 @@ import java.util.Collection;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.BiFunction;
-import org.apache.lucene.document.Field;
 import org.apache.solr.SolrTestCaseJ4;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer;
@@ -142,6 +141,6 @@ public class EmbeddedSolrNoSerializeTest extends SolrTestCaseJ4 {
     QueryResponse rsp = req.process(solrServer);
     assertNotNull(rsp.getResponse().get("tags"));
     assertNotNull(refDoc.get());
-    assertEquals("Boston", ((Field) refDoc.get().getFieldValue("name")).stringValue());
+    assertEquals("Boston", refDoc.get().getFieldValue("name"));
   }
 }


(solr) 02/12: SOLR-17697 Add opt-in picocli command-line parser (#3254)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 7ea60441c4f62a004af55eb920ef495c78c65660
Author: Jan Høydahl <ja...@apache.org>
AuthorDate: Tue Oct 6 15:37:01 2026 +0200

    SOLR-17697 Add opt-in picocli command-line parser (#3254)
    
    Adds picocli alongside the existing commons-cli parser. Picocli is
    opt-in and off by default: set SOLR_PICOCLI=true
    
    Commands ported to picocli in this change:
    
      start, stop, status, version, auth, create, delete
      zk: cp, ls, mv, rm, mkroot, upconfig, downconfig, updateacls
    
    Remaining commands still run on commons-cli and are tracked in
    follow-up issues. Per-command ref guide pages are now generated from
    the picocli annotations.
    
    Co-authored-by: Eric Pugh <ep...@opensourceconnections.com>
    (cherry picked from commit b6b2b8f10e9827e3b86e44649fb7966ce646c185)
---
 .github/workflows/bin-solr-test.yml                |   8 +-
 .../SOLR-17697-picocli-experimental-cli.yml        |  12 +
 dev-docs/ref-guide/antora.adoc                     |  53 +++
 gradle/libs.versions.toml                          |   3 +
 solr/api/gradle.lockfile                           |   1 +
 solr/benchmark/gradle.lockfile                     |   1 +
 solr/bin/solr                                      |   6 +-
 solr/bin/solr.cmd                                  |  18 +-
 solr/core/build.gradle                             |  21 ++
 solr/core/gradle.lockfile                          | 256 ++++++-------
 .../core/src/java/org/apache/solr/cli/ApiTool.java |   5 +
 .../src/java/org/apache/solr/cli/AssertTool.java   |   5 +
 .../src/java/org/apache/solr/cli/AuthTool.java     | 116 ++++++
 .../src/java/org/apache/solr/cli/CLIUtils.java     |   4 +
 .../apache/solr/cli/CliDefaultValueProvider.java}  |  33 +-
 .../src/java/org/apache/solr/cli/ClusterTool.java  |   5 +
 .../org/apache/solr/cli/ConfigSetDownloadTool.java |  95 ++++-
 .../org/apache/solr/cli/ConfigSetOptions.java}     |  37 +-
 .../org/apache/solr/cli/ConfigSetUploadTool.java   |  99 ++++--
 .../src/java/org/apache/solr/cli/ConfigTool.java   |   5 +
 .../org/apache/solr/cli/ConnectionOptions.java     |  76 ++++
 .../src/java/org/apache/solr/cli/CreateTool.java   | 250 ++++++++++---
 .../org/apache/solr/cli/CredentialsOptions.java}   |  31 +-
 .../src/java/org/apache/solr/cli/DeleteTool.java   | 116 +++++-
 .../src/java/org/apache/solr/cli/ExportTool.java   |   5 +
 .../java/org/apache/solr/cli/HealthcheckTool.java  |   5 +
 .../org/apache/solr/cli/HelpMixin.java}            |  31 +-
 .../src/java/org/apache/solr/cli/PackageTool.java  |   5 +
 .../src/java/org/apache/solr/cli/PostLogsTool.java |   5 +
 .../src/java/org/apache/solr/cli/PostTool.java     |   5 +
 .../org/apache/solr/cli/RecursiveOption.java}      |  28 +-
 .../java/org/apache/solr/cli/RunExampleTool.java   |   5 +
 .../org/apache/solr/cli/SnapshotCreateTool.java    |   5 +
 .../org/apache/solr/cli/SnapshotDeleteTool.java    |   5 +
 .../org/apache/solr/cli/SnapshotDescribeTool.java  |   5 +
 .../org/apache/solr/cli/SnapshotExportTool.java    |   5 +
 .../java/org/apache/solr/cli/SnapshotListTool.java |   5 +
 .../core/src/java/org/apache/solr/cli/SolrCLI.java | 212 ++++++++++-
 .../src/java/org/apache/solr/cli/StartCommand.java | 247 +++++++++++++
 .../src/java/org/apache/solr/cli/StatusTool.java   | 193 ++++++++--
 .../src/java/org/apache/solr/cli/StopCommand.java  |  62 ++++
 .../src/java/org/apache/solr/cli/StreamTool.java   |   5 +
 solr/core/src/java/org/apache/solr/cli/Tool.java   |  26 +-
 .../src/java/org/apache/solr/cli/ToolBase.java     |  66 +++-
 .../java/org/apache/solr/cli/UpdateACLTool.java    |  53 ++-
 .../src/java/org/apache/solr/cli/VersionTool.java  |  36 +-
 .../org/apache/solr/cli/ZkConnectionOptions.java   | 123 +++++++
 .../src/java/org/apache/solr/cli/ZkCpTool.java     |  65 +++-
 .../src/java/org/apache/solr/cli/ZkLsTool.java     |  72 +++-
 .../src/java/org/apache/solr/cli/ZkMkrootTool.java |  74 +++-
 .../src/java/org/apache/solr/cli/ZkMvTool.java     | 100 ++++--
 .../src/java/org/apache/solr/cli/ZkRmTool.java     |  90 ++++-
 solr/core/src/java/org/apache/solr/cli/ZkTool.java |  51 +++
 .../src/java/org/apache/solr/cli/ZkToolHelp.java   |   5 +
 .../org/apache/solr/cli/AuthToolPicocliTest.java   |  43 +++
 .../src/test/org/apache/solr/cli/AuthToolTest.java |  10 +-
 ...SolrCLITest.java => CreateToolPicocliTest.java} |  34 +-
 .../test/org/apache/solr/cli/CreateToolTest.java   |   7 +-
 ...SolrCLITest.java => DeleteToolPicocliTest.java} |  34 +-
 .../test/org/apache/solr/cli/DeleteToolTest.java   |  11 +-
 .../org/apache/solr/cli/SolrCLIPicocliTest.java    | 150 ++++++++
 .../src/test/org/apache/solr/cli/SolrCLITest.java  |  24 ++
 ...SolrCLITest.java => StatusToolPicocliTest.java} |  34 +-
 .../test/org/apache/solr/cli/StatusToolTest.java   |  17 +-
 ...olrCLITest.java => VersionToolPicocliTest.java} |  33 +-
 .../test/org/apache/solr/cli/VersionToolTest.java  |  18 +-
 .../apache/solr/cli/ZkSubcommandsPicocliTest.java  |  51 +++
 .../org/apache/solr/cli/ZkSubcommandsTest.java     | 128 +++++--
 solr/cross-dc-manager/gradle.lockfile              |   1 +
 solr/licenses/picocli-4.7.6.jar.sha1               |   1 +
 solr/licenses/picocli-LICENSE-ASL.txt              | 201 +++++++++++
 solr/licenses/picocli-NOTICE.txt                   |   2 +
 solr/modules/analysis-extras/gradle.lockfile       |   1 +
 solr/modules/clustering/gradle.lockfile            |   1 +
 solr/modules/cross-dc/gradle.lockfile              |   1 +
 solr/modules/cuvs/gradle.lockfile                  |   1 +
 solr/modules/extraction/gradle.lockfile            |   1 +
 solr/modules/gcs-repository/gradle.lockfile        |   1 +
 solr/modules/jwt-auth/gradle.lockfile              |   1 +
 solr/modules/langid/gradle.lockfile                |   1 +
 solr/modules/language-models/gradle.lockfile       |   1 +
 solr/modules/ltr/gradle.lockfile                   |   1 +
 solr/modules/opentelemetry/gradle.lockfile         |   1 +
 solr/modules/s3-repository/gradle.lockfile         |   1 +
 solr/modules/scripting/gradle.lockfile             |   1 +
 solr/modules/sql/gradle.lockfile                   |   1 +
 solr/packaging/build.gradle                        |  79 +++--
 solr/packaging/test/test_create.bats               |   2 +-
 solr/packaging/test/test_status.bats               |   2 +-
 solr/packaging/test/test_zk.bats                   |  15 +-
 solr/server/gradle.lockfile                        |   1 +
 solr/solr-ref-guide/README.adoc                    |   6 +
 solr/solr-ref-guide/build.gradle                   | 394 +++++++++++++++++++++
 solr/solr-ref-guide/gradle.lockfile                | 256 ++++++-------
 .../modules/deployment-guide/deployment-nav.adoc   |  19 +
 .../modules/deployment-guide/pages/cli/index.adoc  |  96 +++++
 .../deployment-guide/pages/cli/solr-auth.adoc      | 124 +++++++
 .../deployment-guide/pages/cli/solr-create.adoc    | 112 ++++++
 .../deployment-guide/pages/cli/solr-delete.adoc    | 101 ++++++
 .../deployment-guide/pages/cli/solr-start.adoc     | 128 +++++++
 .../deployment-guide/pages/cli/solr-status.adoc    | 106 ++++++
 .../deployment-guide/pages/cli/solr-stop.adoc      |  80 +++++
 .../deployment-guide/pages/cli/solr-version.adoc   |  70 ++++
 .../deployment-guide/pages/cli/solr-zk-cp.adoc     | 109 ++++++
 .../pages/cli/solr-zk-downconfig.adoc              | 120 +++++++
 .../deployment-guide/pages/cli/solr-zk-ls.adoc     |  93 +++++
 .../deployment-guide/pages/cli/solr-zk-mkroot.adoc |  93 +++++
 .../deployment-guide/pages/cli/solr-zk-mv.adoc     |  97 +++++
 .../deployment-guide/pages/cli/solr-zk-rm.adoc     |  93 +++++
 .../pages/cli/solr-zk-upconfig.adoc                | 120 +++++++
 .../pages/cli/solr-zk-updateacls.adoc              |  90 +++++
 .../deployment-guide/pages/cli/solr-zk.adoc        |  83 +++++
 .../pages/solr-control-script-reference.adoc       |   9 +
 solr/solrj-jetty/gradle.lockfile                   |   1 +
 solr/solrj-streaming/gradle.lockfile               |   1 +
 solr/solrj-zookeeper/gradle.lockfile               |   1 +
 solr/solrj/gradle.lockfile                         |   1 +
 solr/test-framework/build.gradle                   |   2 +
 solr/test-framework/gradle.lockfile                |   1 +
 solr/webapp/gradle.lockfile                        |   1 +
 120 files changed, 5457 insertions(+), 712 deletions(-)

diff --git a/.github/workflows/bin-solr-test.yml b/.github/workflows/bin-solr-test.yml
index 1138de76ba3..d4d7594f5e4 100644
--- a/.github/workflows/bin-solr-test.yml
+++ b/.github/workflows/bin-solr-test.yml
@@ -30,9 +30,15 @@ jobs:
     - name: Test the bin/solr script
       run: ./gradlew integrationTests
 
+    # Reuses the distribution built above, so this only costs the curated suite run.
+    - name: Test the bin/solr script with the experimental picocli CLI
+      run: ./gradlew integrationTestsPicocli
+
     - name: Archive logs
       if: ${{ failure() }}
       uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
       with:
         name: logs
-        path: solr/packaging/build/test-output
+        path: |
+          solr/packaging/build/test-output
+          solr/packaging/build/test-output-picocli
diff --git a/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml b/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml
new file mode 100644
index 00000000000..85cdf1fd90d
--- /dev/null
+++ b/changelog/unreleased/SOLR-17697-picocli-experimental-cli.yml
@@ -0,0 +1,12 @@
+title: >
+  An experimental re-implementation of the `bin/solr` command line interface, built on picocli,
+  is now included. It is disabled by default and is enabled with `SOLR_PICOCLI=true`. Only some
+  commands have been migrated so far, and its options may change between releases. See the
+  "Experimental picocli Command Line Interface" page in the Reference Guide.
+type: added
+authors:
+  - name: Jan Høydahl
+    url: https://home.apache.org/phonebook.html?uid=janhoy
+links:
+  - name: SOLR-17697
+    url: https://issues.apache.org/jira/browse/SOLR-17697
diff --git a/dev-docs/ref-guide/antora.adoc b/dev-docs/ref-guide/antora.adoc
index 0b70cf7f27f..15002a4e6b8 100644
--- a/dev-docs/ref-guide/antora.adoc
+++ b/dev-docs/ref-guide/antora.adoc
@@ -45,6 +45,59 @@ Instead, update `antora.template.yaml`, and `gradlew buildLocalSite` will build
 
 The only reason you will likely need to change the `antora.template.yml` is if you are introducing new variables for dependency versions.
 
+== Auto-generated CLI Reference Pages
+
+The `bin/solr` command-line tool documentation is partially auto-generated from the picocli `@Command`, `@Option`, and `@Parameters` annotations in the Java source.
+
+=== How it works
+
+For each command registered in `org.apache.solr.cli.SolrCLI` that uses picocli annotations, a dedicated AsciiDoc page is generated by `picocli.codegen.docgen.manpage.ManPageGenerator` and committed to:
+
+----
+solr/solr-ref-guide/modules/deployment-guide/pages/cli/
+----
+
+The generated pages are post-processed to be Antora-compatible (correct page title, `:page-toclevels:` attribute, ASF license header, DO NOT EDIT notice, and the experimental warning).
+
+Two things about that post-processing are worth knowing before changing it:
+
+* Whether an option is required is worked out by re-parsing the synopsis that picocli generated, because `ManPageGenerator` emits no per-option marker (https://github.com/remkop/picocli/issues/2519[picocli#2519]).
+  A change to picocli's synopsis layout will therefore change the markers; the build fails loudly rather than silently publishing pages without them.
+* `runManPageGenerator` resolves `project.objects` and the `cliClasspath` configuration at execution time, which is not compatible with Gradle's configuration cache should it ever be enabled for this build.
+The navigation section in `deployment-nav.adoc` between the `// CLI-DOCS-START` and `// CLI-DOCS-END` markers is also updated automatically.
+
+The main landing page `solr-control-script-reference.adoc` provides an overview of the available tools with links to each man-page.
+
+=== Gradle targets
+
+`./gradlew :solr:solr-ref-guide:generateCliDocs`::
+Regenerates all pages under `pages/cli/` from the current picocli annotations and updates the nav.
+Run this after modifying any `@Command`, `@Option`, or `@Parameters` annotation in `solr/core/src/java/org/apache/solr/cli/`.
+
+`./gradlew :solr:solr-ref-guide:checkCliDocsUpToDate`::
+Verifies that the committed pages in `pages/cli/` are in sync with the current annotations.
+It is wired into `gradlew check` alongside the other ref-guide checks, so it is skipped when the ref guide is excluded with `-Prefguide.include=false`.
+Note that upgrading picocli can change the generated wording without any annotation having been touched; if the check fails after a version bump, re-run `generateCliDocs` and commit the result.
+
+=== Adding docs for a new tool
+
+The new command will automatically appear in the generated pages, in the navigation and in the command table on `pages/cli/index.adoc` when running `gradlew generateCliDocs`.
+
+`pages/cli/index.adoc` is the only hand-written page in that directory; everything matching `solr-*.adoc` is generated and must not be edited by hand.
+`solr-control-script-reference.adoc` documents the default (commons-cli) implementation and should not gain per-command links to the generated pages while the picocli implementation is experimental.
+
+=== What a tool migration should include
+
+When migrating a tool to picocli:
+
+* A `*PicocliTest` subclass of the tool's existing test that overrides only the invocation strategy, so every case runs on both implementations. See `ZkSubcommandsPicocliTest` for the pattern.
+* The tool's `.bats` file added to `integrationTestsPicocli` in `solr/packaging/build.gradle`, but only once *every* command that suite invokes has been migrated, not just the one under test.
+  `test_auth.bats`, for example, uses `solr assert` to wait for startup, so it cannot be added until the assert command is migrated.
+  Be aware that a failure part-way through that suite is especially awkward: `solr auth enable` appends `SOLR_AUTH_TYPE` and `SOLR_AUTHENTICATION_OPTS` to the `solr.in.sh` of the *built distribution* under `solr/packaging/build/`, so an aborted run leaves every later `bin/solr` invocation trying to authenticate.
+  If BATS runs start hanging for no apparent reason, check that file, and delete the distribution directory to force `installFullDist` to recreate it.
+* A `gradlew :solr:solr-ref-guide:generateCliDocs` run, with the result committed.
+* The tool registered in `SolrCLI`'s `subcommands` only once `callTool()` actually works; `SolrCLIPicocliTest` enforces the rules that apply to every registered command.
+
 == Building the HTML Site
 
 A Gradle target `gradlew buildLocalSite` will build the full HTML site (found in `solr/solr-ref-guide/build/site`).
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 119587506be..c9631d169e9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -166,6 +166,7 @@ ow2-asm = "9.10.1"
 owasp-dependencycheck = "13.0.0"
 # @keep for version alignment
 perfmark = "0.27.0"
+picocli = "4.7.6"
 prometheus-metrics = "1.8.0"
 quicktheories = "0.26"
 selenium = "4.47.0"
@@ -442,6 +443,8 @@ ow2-asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "ow2-asm"
 ow2-asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "ow2-asm" }
 # @keep transitive dependency for version alignment
 perfmark-api = { module = "io.perfmark:perfmark-api", version.ref = "perfmark" }
+picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
+picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" }
 prometheus-metrics-expositionformats = { module = "io.prometheus:prometheus-metrics-exposition-formats", version.ref = "prometheus-metrics" }
 prometheus-metrics-model = { module = "io.prometheus:prometheus-metrics-model", version.ref = "prometheus-metrics" }
 quicktheories-quicktheories = { module = "org.quicktheories:quicktheories", version.ref = "quicktheories" }
diff --git a/solr/api/gradle.lockfile b/solr/api/gradle.lockfile
index 7db9ea0173d..34b8782d0ae 100644
--- a/solr/api/gradle.lockfile
+++ b/solr/api/gradle.lockfile
@@ -38,6 +38,7 @@ com.tdunning:t-digest:3.3=jarValidation,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=jarValidation,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,testRuntimeClasspath
 commons-io:commons-io:2.22.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,testRuntimeClasspath
 io.github.classgraph:classgraph:4.8.192=swaggerBuild
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/benchmark/gradle.lockfile b/solr/benchmark/gradle.lockfile
index 14a4a985352..4734b8dd4ab 100644
--- a/solr/benchmark/gradle.lockfile
+++ b/solr/benchmark/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/bin/solr b/solr/bin/solr
index 4d05cb09d45..db487c04bfd 100755
--- a/solr/bin/solr
+++ b/solr/bin/solr
@@ -828,7 +828,11 @@ if [ $# -gt 0 ]; then
             shift 2
         ;;
         -h|--help)
-            print_usage "$SCRIPT_CMD"
+            if [[ "${SOLR_PICOCLI:-}" == "true" ]]; then
+                run_tool "$SCRIPT_CMD" --help
+            else
+                print_usage "$SCRIPT_CMD"
+            fi
             exit 0
         ;;
         -y|--no-prompt)
diff --git a/solr/bin/solr.cmd b/solr/bin/solr.cmd
index 67488161995..3867aa1049b 100755
--- a/solr/bin/solr.cmd
+++ b/solr/bin/solr.cmd
@@ -417,8 +417,22 @@ IF "%1"=="--all" goto set_stop_all
 :parse_general_args
 
 REM Print usage of command in case help option included
-IF "%1"=="--help" goto usage
-IF "%1"=="-h" goto usage
+IF "%1"=="--help" goto check_picocli_help
+IF "%1"=="-h" goto check_picocli_help
+goto after_help_check
+
+:check_picocli_help
+IF "%SOLR_PICOCLI%"=="true" goto run_picocli_help
+goto usage
+
+:run_picocli_help
+"%JAVA%" %SOLR_SSL_OPTS% %AUTHC_OPTS% %SOLR_ZK_CREDS_AND_ACLS% %SOLR_TOOL_OPTS% -Dsolr.install.dir="%SOLR_TIP%" ^
+  -Dlog4j.configurationFile="file:///%DEFAULT_SERVER_DIR%\resources\log4j2-console.xml" ^
+  -classpath "%SOLR_TIP%\lib\*;%DEFAULT_SERVER_DIR%\solr-webapp\webapp\WEB-INF\lib\*;%DEFAULT_SERVER_DIR%\lib\ext\*" ^
+  org.apache.solr.cli.SolrCLI %SCRIPT_CMD% --help
+goto done
+
+:after_help_check
 
 REM other args supported by all special commands
 IF "%1"=="-p" goto set_port
diff --git a/solr/core/build.gradle b/solr/core/build.gradle
index be5de41c7ca..9bfe3f8ad62 100644
--- a/solr/core/build.gradle
+++ b/solr/core/build.gradle
@@ -96,6 +96,7 @@ dependencies {
   implementation libs.commonscodec.commonscodec
 
   implementation libs.commonscli.commonscli
+  implementation libs.picocli
 
   implementation libs.locationtech.spatial4j
 
@@ -200,3 +201,23 @@ dependencies {
 
   testImplementation libs.opentelemetry.sdk.testing
 }
+
+// Expose a configuration for CLI doc generation in the solr-ref-guide project.
+// This bundles the full runtime classpath plus picocli-codegen (ManPageGenerator)
+// and the compiled CLI classes so that generateCliDocs can invoke ManPageGenerator.
+configurations {
+  cliDocsRuntime {
+    canBeResolved = true
+    canBeConsumed = true
+    // Inherit all runtime dependencies (implementation + runtimeOnly transitively)
+    extendsFrom configurations.runtimeClasspath
+  }
+}
+
+dependencies {
+  // picocli-codegen provides ManPageGenerator (not in runtime, only needed for doc gen)
+  cliDocsRuntime libs.picocli.codegen
+  // Include the compiled CLI classes themselves
+  cliDocsRuntime files(sourceSets.main.output.classesDirs)
+  cliDocsRuntime files(sourceSets.main.output.resourcesDir)
+}
diff --git a/solr/core/gradle.lockfile b/solr/core/gradle.lockfile
index f8909d62c06..21cbce5409a 100644
--- a/solr/core/gradle.lockfile
+++ b/solr/core/gradle.lockfile
@@ -4,16 +4,16 @@
 # To regenerate this file, run: ./gradlew :solr:core:dependencies --write-locks
 biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
 com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.9.1=jarValidation,testCompileClasspath,testRuntimeClasspath
-com.carrotsearch:hppc:0.11.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-annotations:2.22=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-core:2.22.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-databind:2.22.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.22.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.22.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson:jackson-bom:2.22.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.fasterxml.woodstox:woodstox-core:7.3.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.carrotsearch:hppc:0.11.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-annotations:2.22=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-core:2.22.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-databind:2.22.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.22.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.22.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson:jackson-bom:2.22.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.woodstox:woodstox-core:7.3.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,errorprone,testAnnotationProcessor
 com.github.spotbugs:spotbugs-annotations:4.10.4=compileClasspath,testCompileClasspath
 com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,jarValidation
@@ -21,152 +21,154 @@ com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,error
 com.google.auto.value:auto-value-annotations:1.11.1=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.auto:auto-common:1.2.2=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.errorprone:error_prone_annotation:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor
-com.google.errorprone:error_prone_annotations:2.50.0=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.errorprone:error_prone_annotations:2.50.0=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 com.google.errorprone:error_prone_check_api:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.errorprone:error_prone_core:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,errorprone,testAnnotationProcessor
-com.google.guava:failureaccess:1.0.3=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
-com.google.guava:guava:33.7.1-jre=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
-com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
-com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.guava:failureaccess:1.0.3=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.guava:guava:33.7.1-jre=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 com.google.protobuf:protobuf-java:4.36.2=annotationProcessor,errorprone,testAnnotationProcessor
 com.ibm.icu:icu4j:78.3=jarValidation,testRuntimeClasspath
-com.j256.simplemagic:simplemagic:1.17=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-com.jayway.jsonpath:json-path:3.0.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.j256.simplemagic:simplemagic:1.17=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.jayway.jsonpath:json-path:3.0.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 com.microsoft.onnxruntime:onnxruntime:1.29.0=jarValidation,testRuntimeClasspath
-com.tdunning:t-digest:3.3=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-commons-cli:commons-cli:1.11.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-commons-codec:commons-codec:1.22.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+com.tdunning:t-digest:3.3=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+commons-cli:commons-cli:1.11.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+commons-codec:commons-codec:1.22.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+commons-io:commons-io:2.22.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli-codegen:4.7.6=cliDocsRuntime
+info.picocli:picocli:4.7.6=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.dropwizard.metrics:metrics-core:4.2.39=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
-io.netty:netty-buffer:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-codec-base:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-common:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-handler:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-resolver:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-tcnative-boringssl-static:2.0.84.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-tcnative-classes:2.0.84.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport-classes-epoll:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport-native-epoll:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport-native-unix-common:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport:4.2.18.Final=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.31.1-alpha=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.31.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:2.31.1-alpha=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry.semconv:opentelemetry-semconv:1.43.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-io.opentelemetry:opentelemetry-api-incubator:1.65.0-alpha=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-io.opentelemetry:opentelemetry-api:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-common:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-context:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-exporter-prometheus:1.65.0-alpha=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk-common:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-buffer:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-codec-base:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-common:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-handler:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-resolver:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-tcnative-boringssl-static:2.0.84.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-tcnative-classes:2.0.84.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport-classes-epoll:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport-native-epoll:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport-native-unix-common:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport:4.2.18.Final=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.31.1-alpha=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.31.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:2.31.1-alpha=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry.semconv:opentelemetry-semconv:1.43.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+io.opentelemetry:opentelemetry-api-incubator:1.65.0-alpha=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+io.opentelemetry:opentelemetry-api:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-common:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-context:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-exporter-prometheus:1.65.0-alpha=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk-common:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 io.opentelemetry:opentelemetry-sdk-logs:1.65.0=jarValidation,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk-metrics:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk-metrics:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 io.opentelemetry:opentelemetry-sdk-testing:1.65.0=jarValidation,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk-trace:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk:1.65.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.prometheus:prometheus-metrics-config:1.8.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.prometheus:prometheus-metrics-exposition-formats:1.8.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.prometheus:prometheus-metrics-exposition-textformats:1.8.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.prometheus:prometheus-metrics-model:1.8.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.sgr:s2-geometry-library-java:1.0.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-io.swagger.core.v3:swagger-annotations-jakarta:2.2.54=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-jakarta.activation:jakarta.activation-api:2.1.3=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-jakarta.inject:jakarta.inject-api:2.0.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-jakarta.servlet:jakarta.servlet-api:6.1.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-jakarta.validation:jakarta.validation-api:3.1.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk-trace:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk:1.65.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-config:1.8.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-exposition-formats:1.8.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-exposition-textformats:1.8.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-model:1.8.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.sgr:s2-geometry-library-java:1.0.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+io.swagger.core.v3:swagger-annotations-jakarta:2.2.54=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+jakarta.activation:jakarta.activation-api:2.1.3=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+jakarta.annotation:jakarta.annotation-api:3.0.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+jakarta.inject:jakarta.inject-api:2.0.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+jakarta.servlet:jakarta.servlet-api:6.1.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+jakarta.validation:jakarta.validation-api:3.1.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor
 junit:junit:4.13.2=jarValidation,testCompileClasspath,testRuntimeClasspath
 net.bytebuddy:byte-buddy:1.18.11=jarValidation,testCompileClasspath,testRuntimeClasspath
-org.antlr:antlr4-runtime:4.13.2=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.apache.commons:commons-exec:1.6.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.commons:commons-lang3:3.20.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.commons:commons-math3:3.6.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.curator:curator-client:5.9.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.curator:curator-framework:5.9.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.antlr:antlr4-runtime:4.13.2=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.commons:commons-exec:1.6.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.commons:commons-lang3:3.20.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.commons:commons-math3:3.6.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.curator:curator-client:5.9.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.curator:curator-framework:5.9.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.apache.curator:curator-test:5.9.0=jarValidation,testCompileClasspath,testRuntimeClasspath
-org.apache.logging.log4j:log4j-api:2.26.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.logging.log4j:log4j-core:2.26.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.logging.log4j:log4j-slf4j2-impl:2.26.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.apache.lucene:lucene-analysis-common:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-api:2.26.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-core:2.26.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-slf4j2-impl:2.26.1=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-common:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.apache.lucene:lucene-analysis-icu:10.4.0=jarValidation,testRuntimeClasspath
-org.apache.lucene:lucene-analysis-kuromoji:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-kuromoji:10.4.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.apache.lucene:lucene-analysis-morfologik:10.4.0=jarValidation,testRuntimeClasspath
-org.apache.lucene:lucene-analysis-nori:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-nori:10.4.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.apache.lucene:lucene-analysis-opennlp:10.4.0=jarValidation,testRuntimeClasspath
-org.apache.lucene:lucene-analysis-phonetic:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-phonetic:10.4.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.apache.lucene:lucene-analysis-smartcn:10.4.0=jarValidation,testRuntimeClasspath
 org.apache.lucene:lucene-analysis-stempel:10.4.0=jarValidation,testRuntimeClasspath
-org.apache.lucene:lucene-backward-codecs:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.apache.lucene:lucene-classification:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-codecs:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-core:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-expressions:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-facet:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-grouping:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-highlighter:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-join:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-memory:10.4.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.apache.lucene:lucene-misc:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-queries:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-queryparser:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-sandbox:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-spatial-extras:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-spatial3d:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-suggest:10.4.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-backward-codecs:10.4.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.lucene:lucene-classification:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-codecs:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-core:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-expressions:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-facet:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-grouping:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-highlighter:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-join:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-memory:10.4.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.apache.lucene:lucene-misc:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-queries:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-queryparser:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-sandbox:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-spatial-extras:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-spatial3d:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-suggest:10.4.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.apache.lucene:lucene-test-framework:10.4.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.apache.opennlp:opennlp-dl:2.5.11=jarValidation,testRuntimeClasspath
 org.apache.opennlp:opennlp-tools:2.5.11=jarValidation,testRuntimeClasspath
-org.apache.zookeeper:zookeeper-jute:3.9.5=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.apache.zookeeper:zookeeper:3.9.5=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.zookeeper:zookeeper-jute:3.9.5=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.apache.zookeeper:zookeeper:3.9.5=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.apiguardian:apiguardian-api:1.1.2=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.carrot2:morfologik-fsa:2.1.9=jarValidation,testRuntimeClasspath
 org.carrot2:morfologik-polish:2.1.9=jarValidation,testRuntimeClasspath
 org.carrot2:morfologik-stemming:2.1.9=jarValidation,testRuntimeClasspath
-org.codehaus.woodstox:stax2-api:4.3.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.codehaus.woodstox:stax2-api:4.3.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.eclipse.jetty.ee10:jetty-ee10-servlet:12.0.34=jarValidation,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-client-transport:12.0.34=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-client:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-common:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-hpack:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-client-transport:12.0.34=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-client:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-common:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-hpack:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.eclipse.jetty.http2:jetty-http2-server:12.0.34=jarValidation,testRuntimeClasspath
-org.eclipse.jetty:jetty-alpn-client:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-alpn-java-client:12.0.34=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.eclipse.jetty:jetty-alpn-client:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-alpn-java-client:12.0.34=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.eclipse.jetty:jetty-alpn-java-server:12.0.34=jarValidation,testRuntimeClasspath
 org.eclipse.jetty:jetty-alpn-server:12.0.34=jarValidation,testRuntimeClasspath
-org.eclipse.jetty:jetty-client:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-http:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-io:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-client:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-http:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-io:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.eclipse.jetty:jetty-rewrite:12.0.34=jarValidation,testRuntimeClasspath
-org.eclipse.jetty:jetty-security:12.0.34=jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-server:12.0.34=jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-security:12.0.34=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-server:12.0.34=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.eclipse.jetty:jetty-session:12.0.34=jarValidation,testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-util:12.0.34=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.hk2.external:aopalliance-repackaged:4.0.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.hk2:hk2-api:4.0.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.hk2:hk2-locator:4.0.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.glassfish.hk2:hk2-utils:4.0.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.hk2:osgi-resource-locator:3.0.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.glassfish.jersey.core:jersey-client:4.0.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.jersey.core:jersey-common:4.0.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.jersey.core:jersey-server:4.0.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.jersey.inject:jersey-hk2:4.0.2=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.glassfish.jersey:jersey-bom:4.0.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-util:12.0.34=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.hk2.external:aopalliance-repackaged:4.0.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.hk2:hk2-api:4.0.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.hk2:hk2-locator:4.0.1=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.glassfish.hk2:hk2-utils:4.0.1=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.hk2:osgi-resource-locator:3.0.0=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.glassfish.jersey.core:jersey-client:4.0.2=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.jersey.core:jersey-common:4.0.2=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.jersey.core:jersey-server:4.0.2=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.jersey.inject:jersey-hk2:4.0.2=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.glassfish.jersey:jersey-bom:4.0.2=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.hamcrest:hamcrest:3.0=jarValidation,testCompileClasspath,testRuntimeClasspath
-org.javassist:javassist:3.30.2-GA=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.jspecify:jspecify:1.0.1=annotationProcessor,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+org.javassist:javassist:3.30.2-GA=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.jspecify:jspecify:1.0.1=annotationProcessor,cliDocsRuntime,compileClasspath,errorprone,jarValidation,runtimeClasspath,runtimeLibs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 org.junit.jupiter:junit-jupiter-api:5.6.2=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.junit.platform:junit-platform-commons:1.6.2=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.junit:junit-bom:5.6.2=jarValidation,testCompileClasspath,testRuntimeClasspath
-org.locationtech.spatial4j:spatial4j:0.8=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.locationtech.spatial4j:spatial4j:0.8=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 org.mockito:mockito-core:5.23.0=jarValidation,testCompileClasspath,testRuntimeClasspath
 org.mockito:mockito-subclass:5.23.0=jarValidation,testRuntimeClasspath
 org.objenesis:objenesis:3.3=jarValidation,testRuntimeClasspath
@@ -175,13 +177,13 @@ org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
 org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
 org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
 org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
-org.ow2.asm:asm-commons:9.10.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.ow2.asm:asm-tree:9.10.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.ow2.asm:asm:9.10.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.ow2.asm:asm-commons:9.10.1=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.ow2.asm:asm-tree:9.10.1=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.ow2.asm:asm:9.10.1=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 org.pcollections:pcollections:4.0.1=annotationProcessor,errorprone,testAnnotationProcessor
-org.semver4j:semver4j:6.0.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.slf4j:jcl-over-slf4j:2.0.20=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
-org.slf4j:slf4j-api:2.0.20=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
-org.xerial.snappy:snappy-java:1.1.10.8=jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.semver4j:semver4j:6.0.0=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.slf4j:jcl-over-slf4j:2.0.20=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+org.slf4j:slf4j-api:2.0.20=cliDocsRuntime,compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
+org.xerial.snappy:snappy-java:1.1.10.8=cliDocsRuntime,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 ua.net.nlp:morfologik-ukrainian-search:4.9.1=jarValidation,testRuntimeClasspath
 empty=missingdoclet
diff --git a/solr/core/src/java/org/apache/solr/cli/ApiTool.java b/solr/core/src/java/org/apache/solr/cli/ApiTool.java
index 51b883d507d..3f174ed58b8 100644
--- a/solr/core/src/java/org/apache/solr/cli/ApiTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ApiTool.java
@@ -121,4 +121,9 @@ public class ApiTool extends ToolBase {
     }
     return paramsMap;
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/AssertTool.java b/solr/core/src/java/org/apache/solr/cli/AssertTool.java
index 0bc56bd2209..5ba193f9494 100644
--- a/solr/core/src/java/org/apache/solr/cli/AssertTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/AssertTool.java
@@ -494,6 +494,11 @@ public class AssertTool extends ToolBase {
     }
   }
 
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
+
   public static class AssertionFailureException extends Exception {
     public AssertionFailureException(String message) {
       super(message);
diff --git a/solr/core/src/java/org/apache/solr/cli/AuthTool.java b/solr/core/src/java/org/apache/solr/cli/AuthTool.java
index d4d7282b32b..b63a0d545dd 100644
--- a/solr/core/src/java/org/apache/solr/cli/AuthTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/AuthTool.java
@@ -46,6 +46,27 @@ import org.apache.solr.security.Sha256AuthenticationProvider;
 import org.apache.zookeeper.KeeperException;
 
 /** Supports auth command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "auth",
+    description =
+        "Updates or enables/disables authentication. Must be run on the Solr server itself.",
+    exitCodeListHeading = "%nExit Codes:%n",
+    exitCodeList = {
+      "0:Authentication was enabled or disabled successfully.",
+      "1:Failed to enable or disable authentication; check output for details."
+    },
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Enable basic authentication with the given credentials",
+      "  bin/solr auth enable --type basicAuth --credentials solr:SolrRocks",
+      "",
+      "  # Enable basic authentication, prompting for credentials",
+      "  bin/solr auth enable --type basicAuth --prompt true",
+      "",
+      "  # Disable authentication",
+      "  bin/solr auth disable"
+    })
 public class AuthTool extends ToolBase {
 
   private static final Option TYPE_OPTION =
@@ -121,6 +142,67 @@ public class AuthTool extends ToolBase {
       boolean zkHostSpecified,
       String zkHost) {}
 
+  // --- picocli fields ---
+
+  @picocli.CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      paramLabel = "enable|disable",
+      description = "Whether to enable or disable authentication.")
+  private String authCommand;
+
+  @picocli.CommandLine.Option(
+      names = {"--type"},
+      paramLabel = "TYPE",
+      defaultValue = "basicAuth",
+      description =
+          "The authentication mechanism to enable (currently only basicAuth). Defaults to 'basicAuth'.")
+  private String type;
+
+  @picocli.CommandLine.Option(
+      names = {"--prompt"},
+      paramLabel = "true|false",
+      description =
+          "Prompts the user to provide the credentials. Use either --credentials or --prompt, not both.")
+  private String promptOption;
+
+  @picocli.CommandLine.Option(
+      names = {"--block-unknown"},
+      paramLabel = "true|false",
+      description =
+          "Blocks all access for unknown users (requires authentication for all endpoints).")
+  private String blockUnknown;
+
+  @picocli.CommandLine.Option(
+      names = {"--solr-include-file"},
+      paramLabel = "FILE",
+      required = true,
+      description =
+          "The Solr include file which contains overridable environment variables for configuring Solr configurations. Defaults to solr.in.sh or solr.in.cmd.")
+  private String solrIncludeFile;
+
+  @picocli.CommandLine.Option(
+      names = {"--update-include-file-only"},
+      paramLabel = "true|false",
+      description =
+          "Only update the solr.in.sh or solr.in.cmd file, and skip actual enabling/disabling"
+              + " authentication (i.e. don't update security.json).")
+  private String updateIncludeFileOnly;
+
+  @picocli.CommandLine.Option(
+      names = {"--auth-conf-dir"},
+      paramLabel = "DIR",
+      required = true,
+      description =
+          "This is where any authentication related configuration files, if any, would be placed. Defaults to $SOLR_HOME.")
+  private String authConfDir;
+
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  public AuthTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public AuthTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -497,4 +579,38 @@ public class AuthTool extends ToolBase {
       throw new IllegalStateException("Only type=basicAuth supported at the moment.");
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    ensureArgumentIsValidBooleanIfPresent(BLOCK_UNKNOWN_OPTION.getLongOpt(), blockUnknown);
+    ensureArgumentIsValidBooleanIfPresent(
+        UPDATE_INCLUDE_FILE_OPTION.getLongOpt(), updateIncludeFileOnly);
+
+    // switch structure is here to support future auth options like oAuth
+    if (!"basicAuth".equals(type)) {
+      throw new IllegalStateException("Only type=basicAuth supported at the moment.");
+    }
+
+    boolean updateIncludeFileOnlyBool = Boolean.parseBoolean(updateIncludeFileOnly);
+    String zkHost = null;
+    if (!updateIncludeFileOnlyBool) {
+      try {
+        zkHost = zkOpts.resolveZkHost();
+      } catch (Exception e) {
+        echoIfVerbose("Could not resolve ZooKeeper host: " + e.getMessage());
+      }
+    }
+    AuthParams params =
+        new AuthParams(
+            Boolean.parseBoolean(promptOption),
+            zkOpts.credentials,
+            blockUnknown,
+            updateIncludeFileOnlyBool,
+            solrIncludeFile,
+            authConfDir,
+            zkOpts.zkHost != null || zkOpts.solrConnection != null,
+            zkHost);
+    handleCommand(authCommand, params);
+    return 0;
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/CLIUtils.java b/solr/core/src/java/org/apache/solr/cli/CLIUtils.java
index 06ed95eae07..68d4eedc470 100644
--- a/solr/core/src/java/org/apache/solr/cli/CLIUtils.java
+++ b/solr/core/src/java/org/apache/solr/cli/CLIUtils.java
@@ -221,7 +221,11 @@ public final class CLIUtils {
   /**
    * Get the base URL of a live Solr instance from either the --solr-url command-line option or from
    * SolrCloud.
+   *
+   * @deprecated Only used by the commons-cli parser. Picocli tools resolve the URL from their own
+   *     annotated connection options and call {@link #normalizeSolrUrl(String)}.
    */
+  @Deprecated
   public static String normalizeSolrUrl(CommandLine cli) throws Exception {
     String solrUrl = cli.getOptionValue(CommonCLIOptions.SOLR_URL_OPTION);
 
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java
similarity index 51%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java
index d045ae35501..bb0521c94d8 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/java/org/apache/solr/cli/CliDefaultValueProvider.java
@@ -16,24 +16,21 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import org.apache.solr.common.util.EnvUtils;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
-
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
+/** Provides default values for CLI arguments. */
+public class CliDefaultValueProvider implements CommandLine.IDefaultValueProvider {
+  @Override
+  public String defaultValue(CommandLine.Model.ArgSpec argSpec) throws Exception {
+    return switch (argSpec.paramLabel()) {
+      case "<zkHost>" -> EnvUtils.getProperty("zkHost");
+      case "<solrConnection>" -> EnvUtils.getProperty("solr-connection");
+      case "<solrUrl>" -> EnvUtils.getProperty("solr.url");
+      // Must match CLIUtils.getDefaultSolrUrl(), which reads solr.port.listen
+      case "<port>" -> EnvUtils.getProperty("solr.port.listen", "8983");
+      case "<maxWaitSecs>" -> EnvUtils.getProperty("solr.max.wait.seconds", "0");
+      default -> null;
+    };
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ClusterTool.java b/solr/core/src/java/org/apache/solr/cli/ClusterTool.java
index f331725b1e3..ce43eff54f1 100644
--- a/solr/core/src/java/org/apache/solr/cli/ClusterTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ClusterTool.java
@@ -108,4 +108,9 @@ public class ClusterTool extends ToolBase {
       }
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ConfigSetDownloadTool.java b/solr/core/src/java/org/apache/solr/cli/ConfigSetDownloadTool.java
index 5623794626c..33d7727f6e2 100644
--- a/solr/core/src/java/org/apache/solr/cli/ConfigSetDownloadTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ConfigSetDownloadTool.java
@@ -19,14 +19,28 @@ package org.apache.solr.cli;
 import java.lang.invoke.MethodHandles;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
+import org.apache.solr.client.solrj.impl.SolrZkClientTimeout;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Supports zk downconfig command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "downconfig",
+    description = "Download a configset from ZooKeeper to the local filesystem.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Download a configset from ZooKeeper",
+      "  bin/solr zk downconfig -n myconfig -d /local/conf -z localhost:9983",
+      "",
+      "  # Download into a path relative to the Solr installation",
+      "  bin/solr zk downconfig -n myconfig -d server/solr/configsets/myconfig/conf -z localhost:9983"
+    })
 public class ConfigSetDownloadTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
@@ -48,6 +62,31 @@ public class ConfigSetDownloadTool extends ToolBase {
           .desc("Local directory with configs.")
           .get();
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Mixin ConfigSetOptions configSetOpts;
+
+  @picocli.CommandLine.Option(
+      names = {"-d", "--conf-dir"},
+      description =
+          """
+        Local directory for configs.
+        The path to write the downloaded configuration set into. If just a name is supplied, `$SOLR_TIP/server/solr/configsets` will be the parent. An absolute path may be supplied as well.
+
+        In either case, _pre-existing configurations at the destination will be overwritten_!
+
+        **Examples:**
+        * `-d directory_under_configsets`
+        * `-d /path/to/configset/destination`
+        """,
+      required = true,
+      paramLabel = "DIR")
+  public String confDir;
+
+  public ConfigSetDownloadTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ConfigSetDownloadTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -74,28 +113,50 @@ public class ConfigSetDownloadTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     String zkHost = CLIUtils.getZkHost(cli);
-
     String confName = cli.getOptionValue(CONF_NAME_OPTION);
     String confDir = cli.getOptionValue(CONF_DIR_OPTION);
 
     echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
     try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
-      Path configSetPath = Path.of(confDir);
-      // we try to be nice about having the "conf" in the directory, and we create it if it's not
-      // there.
-      if (!configSetPath.endsWith("/conf")) {
-        configSetPath = configSetPath.resolve("conf");
-      }
-      Files.createDirectories(configSetPath);
-      echo(
-          "Downloading configset "
-              + confName
-              + " from ZooKeeper at "
-              + zkHost
-              + " to directory "
-              + configSetPath.toAbsolutePath());
-
-      zkClient.downConfig(confName, configSetPath);
+      doDownconfig(zkClient, zkHost, confName, confDir);
+    } catch (Exception e) {
+      log.error("Could not complete downconfig operation for reason: ", e);
+      throw (e);
+    }
+  }
+
+  private void doDownconfig(SolrZkClient zkClient, String zkHost, String confName, String confDir)
+      throws Exception {
+    Path configSetPath = Path.of(confDir);
+    // we try to be nice about having the "conf" in the directory, and we create it if it's not
+    // there.
+    if (!configSetPath.endsWith("conf")) {
+      configSetPath = configSetPath.resolve("conf");
+    }
+    Files.createDirectories(configSetPath);
+    echo(
+        "Downloading configset "
+            + confName
+            + " from ZooKeeper at "
+            + zkHost
+            + " to directory "
+            + configSetPath.toAbsolutePath());
+
+    zkClient.downConfig(confName, configSetPath);
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
+
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doDownconfig(zkClient, zkHost, configSetOpts.confName, confDir);
+      return 0;
     } catch (Exception e) {
       log.error("Could not complete downconfig operation for reason: ", e);
       throw (e);
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/java/org/apache/solr/cli/ConfigSetOptions.java
similarity index 50%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/java/org/apache/solr/cli/ConfigSetOptions.java
index d045ae35501..c5b2b1a77ad 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/java/org/apache/solr/cli/ConfigSetOptions.java
@@ -16,24 +16,29 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
+/**
+ * Picocli mixin providing common configset options shared by upconfig and downconfig sub-commands.
+ *
+ * <p>Use {@code @CommandLine.Mixin ConfigSetOptions configSetOpts} in a command class to inherit
+ * these options.
+ */
+public class ConfigSetOptions {
+
+  @CommandLine.Option(
+      names = {"-n", "--conf-name"},
+      description =
+          """
+          Configset name in ZooKeeper.
+          Name of the configuration set under the "/configs" ZooKeeper node.
 
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
+          You can see available configuration sets in the Admin UI via the Cloud screens. Choose Cloud → Tree → configs to see them.
 
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
+          If a pre-existing configuration set is specified, it will be overwritten in ZooKeeper.
 
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
-  }
+          **Example:** `-n myconfig`
+          """,
+      required = true)
+  public String confName;
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ConfigSetUploadTool.java b/solr/core/src/java/org/apache/solr/cli/ConfigSetUploadTool.java
index 71daf15d338..dae7174605a 100644
--- a/solr/core/src/java/org/apache/solr/cli/ConfigSetUploadTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ConfigSetUploadTool.java
@@ -18,9 +18,11 @@ package org.apache.solr.cli;
 
 import java.lang.invoke.MethodHandles;
 import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
+import org.apache.solr.client.solrj.impl.SolrZkClientTimeout;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.apache.solr.common.cloud.ZkMaintenanceUtils;
 import org.apache.solr.common.util.EnvUtils;
@@ -30,6 +32,18 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Supports zk upconfig command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "upconfig",
+    description = "Upload a configset from the local filesystem to ZooKeeper.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Upload a configset to ZooKeeper",
+      "  bin/solr zk upconfig -n myconfig -d /local/conf -z localhost:9983",
+      "",
+      "  # Upload using a path relative to the Solr installation",
+      "  bin/solr zk upconfig -n myconfig -d server/solr/configsets/myconfig/conf -z localhost:9983"
+    })
 public class ConfigSetUploadTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
@@ -51,6 +65,31 @@ public class ConfigSetUploadTool extends ToolBase {
           .desc("Local directory with configs.")
           .get();
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Mixin ConfigSetOptions configSetOpts;
+
+  @picocli.CommandLine.Option(
+      names = {"-d", "--conf-dir"},
+      description =
+          """
+          Local directory for configs.
+          The local directory of the configuration set to upload. It should have a `conf` directory immediately below it that in turn contains `solrconfig.xml` etc.
+
+          If just a name is supplied, `$SOLR_TIP/server/solr/configsets` will be checked for this name. An absolute path may be supplied instead.
+
+          **Examples:**
+          * `-d directory_under_configsets`
+          * `-d /path/to/configset/source`
+          """,
+      required = true,
+      paramLabel = "DIR")
+  public String confDir;
+
+  public ConfigSetUploadTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ConfigSetUploadTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -77,32 +116,52 @@ public class ConfigSetUploadTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     String zkHost = CLIUtils.getZkHost(cli);
-
-    final String solrInstallDir = EnvUtils.getProperty("solr.install.dir");
-    Path solrInstallDirPath = Path.of(solrInstallDir);
-
     String confName = cli.getOptionValue(CONF_NAME_OPTION);
     String confDir = cli.getOptionValue(CONF_DIR_OPTION);
 
     echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
     try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
-      final Path configsetsDirPath = CLIUtils.getConfigSetsDir(solrInstallDirPath);
-      Path confPath = ConfigSetService.getConfigsetPath(confDir, configsetsDirPath.toString());
-
-      echo(
-          "Uploading "
-              + confPath.toAbsolutePath()
-              + " for config "
-              + confName
-              + " to ZooKeeper at "
-              + zkHost);
-      FileTypeMagicUtil.assertConfigSetFolderLegal(confPath);
-      ZkMaintenanceUtils.uploadToZK(
-          zkClient,
-          confPath,
-          ZkMaintenanceUtils.CONFIGS_ZKNODE + "/" + confName,
-          ZkMaintenanceUtils.UPLOAD_FILENAME_EXCLUDE_PATTERN);
+      doUpconfig(zkClient, zkHost, confName, confDir);
+    } catch (Exception e) {
+      log.error("Could not complete upconfig operation for reason: ", e);
+      throw (e);
+    }
+  }
+
+  private void doUpconfig(SolrZkClient zkClient, String zkHost, String confName, String confDir)
+      throws Exception {
+    final String solrInstallDir = EnvUtils.getProperty("solr.install.dir");
+    Path solrInstallDirPath = Path.of(solrInstallDir);
+    final Path configsetsDirPath = CLIUtils.getConfigSetsDir(solrInstallDirPath);
+    Path confPath = ConfigSetService.getConfigsetPath(confDir, configsetsDirPath.toString());
+
+    echo(
+        "Uploading "
+            + confPath.toAbsolutePath()
+            + " for config "
+            + confName
+            + " to ZooKeeper at "
+            + zkHost);
+    FileTypeMagicUtil.assertConfigSetFolderLegal(confPath);
+    ZkMaintenanceUtils.uploadToZK(
+        zkClient,
+        confPath,
+        ZkMaintenanceUtils.CONFIGS_ZKNODE + "/" + confName,
+        ZkMaintenanceUtils.UPLOAD_FILENAME_EXCLUDE_PATTERN);
+  }
 
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
+
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doUpconfig(zkClient, zkHost, configSetOpts.confName, confDir);
+      return 0;
     } catch (Exception e) {
       log.error("Could not complete upconfig operation for reason: ", e);
       throw (e);
diff --git a/solr/core/src/java/org/apache/solr/cli/ConfigTool.java b/solr/core/src/java/org/apache/solr/cli/ConfigTool.java
index 377cd6a4481..4c0ac64935d 100644
--- a/solr/core/src/java/org/apache/solr/cli/ConfigTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ConfigTool.java
@@ -162,4 +162,9 @@ public class ConfigTool extends ToolBase {
       }
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ConnectionOptions.java b/solr/core/src/java/org/apache/solr/cli/ConnectionOptions.java
new file mode 100644
index 00000000000..0a782b5a60f
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cli/ConnectionOptions.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.io.IOException;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import picocli.CommandLine;
+
+/**
+ * Picocli ArgGroup for mutually-exclusive Solr URL / ZooKeeper connection options.
+ *
+ * <p>Use as the type of an {@code @ArgGroup(exclusive = true, multiplicity = "0..1")} field to
+ * ensure the user provides at most one of {@code --solr-connection}, {@code --solr-url} or {@code
+ * --zk-host}.
+ */
+class ConnectionOptions {
+  @CommandLine.Option(
+      names = {"-s", "--solr-connection"},
+      description =
+          "Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to "
+              + CommonCLIOptions.DefaultValues.ZK_HOST
+              + ".")
+  String solrConnection;
+
+  @CommandLine.Option(
+      names = {"--solr-url"},
+      description =
+          "Base Solr URL, which can be used to determine the zk-host if that's not known.")
+  String solrUrl;
+
+  @CommandLine.Option(
+      names = {"-z", "--zk-host"},
+      description =
+          "Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to "
+              + CommonCLIOptions.DefaultValues.ZK_HOST
+              + ".")
+  String zkHost;
+
+  /**
+   * The effective ZooKeeper connection string, taking {@code --solr-connection} into account, or
+   * null if the user targeted Solr via a URL (or gave no target at all).
+   */
+  String effectiveZkHost() throws IOException {
+    if (solrConnection != null) {
+      var connection = CloudSolrClient.CloudSolrClientConnection.parse(solrConnection);
+      return connection.isZookeeper() ? solrConnection : null;
+    }
+    return zkHost;
+  }
+
+  /**
+   * The effective Solr URL, taking {@code --solr-connection} into account, or null if the user
+   * targeted ZooKeeper (or gave no target at all).
+   */
+  String effectiveSolrUrl() throws IOException {
+    if (solrConnection != null) {
+      var connection = CloudSolrClient.CloudSolrClientConnection.parse(solrConnection);
+      return connection.isZookeeper() ? null : connection.quorumItems().get(0);
+    }
+    return solrUrl;
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/cli/CreateTool.java b/solr/core/src/java/org/apache/solr/cli/CreateTool.java
index 6b7c6549503..25bf09db871 100644
--- a/solr/core/src/java/org/apache/solr/cli/CreateTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/CreateTool.java
@@ -21,6 +21,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.CommandLine;
@@ -42,6 +43,27 @@ import org.apache.solr.common.util.EnvUtils;
 import org.apache.solr.core.ConfigSetService;
 
 /** Supports create command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "create",
+    description =
+        "Creates a core or collection depending on whether Solr is running in standalone (core) or SolrCloud mode (collection).",
+    exitCodeListHeading = "%nExit Codes:%n",
+    exitCodeList = {
+      "0:Collection or core created successfully.",
+      "1:Failed to create; Solr may not be running, or the collection/core already exists."
+    },
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Create a collection or core",
+      "  bin/solr create -c myCollection",
+      "",
+      "  # Create a collection with 2 shards and 2 replicas",
+      "  bin/solr create -c myCollection -s 2 -rf 2",
+      "",
+      "  # Create a collection using an existing configset",
+      "  bin/solr create -c myCollection -d myconfig"
+    })
 public class CreateTool extends ToolBase {
 
   private static final Option COLLECTION_NAME_OPTION =
@@ -91,6 +113,61 @@ public class CreateTool extends ToolBase {
           .desc("Configuration name; default is the collection name.")
           .get();
 
+  /** Options bean shared between commons-cli and picocli paths. */
+  record CreateParams(
+      String name,
+      String confDir,
+      String confName,
+      String solrUrl,
+      boolean explicitConnection,
+      String credentials,
+      int shards,
+      int replicationFactor) {}
+
+  // --- picocli fields ---
+
+  @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1")
+  private ConnectionOptions connectionOptions;
+
+  @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions;
+
+  @picocli.CommandLine.Option(
+      names = {"-c", "--name"},
+      required = true,
+      description = "Name of collection or core to create.")
+  private String name;
+
+  @picocli.CommandLine.Option(
+      names = {"-sh", "--shards"},
+      description = "Number of shards; default is 1.",
+      defaultValue = "1")
+  private int shards;
+
+  @picocli.CommandLine.Option(
+      names = {"-rf", "--replication-factor"},
+      description =
+          "Number of copies of each document across the collection (replicas per shard); default is 1.",
+      defaultValue = "1")
+  private int replicationFactor;
+
+  @picocli.CommandLine.Option(
+      names = {"-d", "--conf-dir"},
+      description =
+          "Configuration directory to copy when creating the new collection; default is "
+              + DefaultValues.DEFAULT_CONFIG_SET
+              + ".",
+      defaultValue = DefaultValues.DEFAULT_CONFIG_SET)
+  private String confDir;
+
+  @picocli.CommandLine.Option(
+      names = {"-n", "--conf-name"},
+      description = "Configuration name; default is the collection name.")
+  private String confName;
+
+  public CreateTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public CreateTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -124,44 +201,47 @@ public class CreateTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     try (var solrClient = CLIUtils.getSolrClient(cli)) {
+      CreateParams params =
+          new CreateParams(
+              cli.getOptionValue(COLLECTION_NAME_OPTION),
+              cli.getOptionValue(CONF_DIR_OPTION, DefaultValues.DEFAULT_CONFIG_SET),
+              cli.getOptionValue(CONF_NAME_OPTION),
+              CLIUtils.normalizeSolrUrl(cli),
+              CLIUtils.hasConnectionOption(cli),
+              cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION),
+              cli.getParsedOptionValue(SHARDS_OPTION, 1),
+              cli.getParsedOptionValue(REPLICATION_FACTOR_OPTION, 1));
       if (CLIUtils.isCloudMode(solrClient)) {
-        createCollection(cli);
+        createCollection(CLIUtils.getZkHost(cli), params);
       } else {
-        createCore(cli, solrClient);
+        createCore(params, solrClient);
       }
     }
   }
 
-  protected void createCore(CommandLine cli, SolrClient solrClient) throws Exception {
-    String coreName = cli.getOptionValue(COLLECTION_NAME_OPTION);
-    String solrUrl = CLIUtils.normalizeSolrUrl(cli);
-
-    final String solrInstallDir = EnvUtils.getProperty("solr.install.dir");
-    final String confDirName =
-        cli.getOptionValue(CONF_DIR_OPTION, DefaultValues.DEFAULT_CONFIG_SET);
-
+  private void createCore(CreateParams params, SolrClient solrClient) throws Exception {
     // we allow them to pass a directory instead of a configset name
-    Path configsetDir = Path.of(confDirName);
-    Path solrInstallDirPath = Path.of(solrInstallDir);
+    Path configsetDir = Path.of(params.confDir);
+    Path solrInstallDirPath = Path.of(EnvUtils.getProperty("solr.install.dir"));
 
     if (!Files.isDirectory(configsetDir)) {
       ensureConfDirExists(solrInstallDirPath, configsetDir);
     }
-    printDefaultConfigsetWarningIfNecessary(cli);
+    printDefaultConfigsetWarning(params);
 
     SystemInfoResponse sysResponse = (new SystemInfoRequest()).process(solrClient);
     // usually same as solr home, but not always
     String coreRootDirectory = sysResponse.getCoreRoot();
 
-    if (CLIUtils.safeCheckCoreExists(
-        solrUrl, coreName, cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION))) {
+    String solrUrl = params.solrUrl != null ? params.solrUrl : CLIUtils.getDefaultSolrUrl();
+    if (CLIUtils.safeCheckCoreExists(solrUrl, params.name, params.credentials)) {
       throw new IllegalArgumentException(
           "\nCore '"
-              + coreName
+              + params.name
               + "' already exists!\nChecked core existence using Core API command");
     }
 
-    Path coreInstanceDir = Path.of(coreRootDirectory, coreName);
+    Path coreInstanceDir = Path.of(coreRootDirectory, params.name);
     Path confDir = getFullConfDir(solrInstallDirPath, configsetDir).resolve("conf");
     if (!Files.isDirectory(coreInstanceDir)) {
       Files.createDirectories(coreInstanceDir);
@@ -177,14 +257,14 @@ public class CreateTool extends ToolBase {
               + coreInstanceDir.toAbsolutePath());
     }
 
-    echoIfVerbose("\nCreating new core '" + coreName + "' using V2 Cores API");
+    echoIfVerbose("\nCreating new core '" + params.name + "' using V2 Cores API");
 
     try {
       var req = new CoresApi.CreateCore();
-      req.setName(coreName);
-      req.setInstanceDir(coreName);
+      req.setName(params.name);
+      req.setInstanceDir(params.name);
       req.process(solrClient);
-      echo(String.format(Locale.ROOT, "\nCreated new core '%s'", coreName));
+      echo(String.format(Locale.ROOT, "\nCreated new core '%s'", params.name));
 
     } catch (Exception e) {
       /* create-core failed, cleanup the copied configset before propagating the error. */
@@ -193,15 +273,13 @@ public class CreateTool extends ToolBase {
     }
   }
 
-  protected void createCollection(CommandLine cli) throws Exception {
+  private void createCollection(String zkHost, CreateParams params) throws Exception {
     var builder =
         new HttpJettySolrClient.Builder()
             .withIdleTimeout(30, TimeUnit.SECONDS)
             .withConnectionTimeout(15, TimeUnit.SECONDS)
             .withKeyStoreReloadInterval(-1, TimeUnit.SECONDS)
-            .withOptionalBasicAuthCredentials(
-                cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION));
-    String zkHost = CLIUtils.getZkHost(cli);
+            .withOptionalBasicAuthCredentials(params.credentials);
     echoIfVerbose("Connecting to ZooKeeper at " + zkHost);
     var zkSolrConnection = CloudSolrClient.CloudSolrClientConnection.parse(zkHost);
     if (!zkSolrConnection.isZookeeper()) {
@@ -210,21 +288,17 @@ public class CreateTool extends ToolBase {
               Locale.ROOT, "Expected ZooKeeper connection string, but got: '%s'.", zkHost));
     }
     try (var cloudSolrClient = CLIUtils.getCloudSolrClient(zkSolrConnection, builder)) {
-      createCollection(cloudSolrClient, cli);
+      createCollection(cloudSolrClient, params);
     }
   }
 
-  protected void createCollection(CloudSolrClient cloudSolrClient, CommandLine cli)
+  private void createCollection(CloudSolrClient cloudSolrClient, CreateParams params)
       throws Exception {
 
-    String collectionName = cli.getOptionValue(COLLECTION_NAME_OPTION);
-    final String solrInstallDir = EnvUtils.getProperty("solr.install.dir");
-    String confName = cli.getOptionValue(CONF_NAME_OPTION);
-    String confDir = cli.getOptionValue(CONF_DIR_OPTION, DefaultValues.DEFAULT_CONFIG_SET);
-    Path solrInstallDirPath = Path.of(solrInstallDir);
-    Path confDirPath = Path.of(confDir);
+    Path solrInstallDirPath = Path.of(EnvUtils.getProperty("solr.install.dir"));
+    Path confDirPath = Path.of(params.confDir);
     ensureConfDirExists(solrInstallDirPath, confDirPath);
-    printDefaultConfigsetWarningIfNecessary(cli);
+    printDefaultConfigsetWarning(params);
 
     Set<String> liveNodes = cloudSolrClient.getClusterState().getLiveNodes();
     if (liveNodes.isEmpty())
@@ -232,17 +306,16 @@ public class CreateTool extends ToolBase {
           "No live nodes found! Cannot create a collection until "
               + "there is at least 1 live node in the cluster.");
 
-    String solrUrl;
-    if (CLIUtils.hasConnectionOption(cli)) {
-      solrUrl = CLIUtils.normalizeSolrUrl(cli);
-    } else {
+    String solrUrl = params.explicitConnection ? params.solrUrl : null;
+    if (solrUrl == null) {
       String firstLiveNode = liveNodes.iterator().next();
       solrUrl = ZkStateReader.from(cloudSolrClient).getBaseUrlForNodeName(firstLiveNode);
     }
 
     // build a URL to create the collection
-    int numShards = cli.getParsedOptionValue(SHARDS_OPTION, 1);
-    int replicationFactor = cli.getParsedOptionValue(REPLICATION_FACTOR_OPTION, 1);
+    int numShards = params.shards;
+    int replicationFactor = params.replicationFactor;
+    String confName = params.confName;
 
     boolean configExistsInZk =
         confName != null
@@ -253,7 +326,7 @@ public class CreateTool extends ToolBase {
       echo("Re-using existing configuration directory " + confName);
     } else { // if (confdir != null && !confdir.trim().isEmpty()) {
       if (confName == null || confName.trim().isEmpty()) {
-        confName = collectionName;
+        confName = params.name;
       }
 
       // TODO: This should be done using the configSet API.  This would let us remove
@@ -262,7 +335,8 @@ public class CreateTool extends ToolBase {
       final Path configsetsDirPath = CLIUtils.getConfigSetsDir(solrInstallDirPath);
       ConfigSetService configSetService =
           new ZkConfigSetService(ZkStateReader.from(cloudSolrClient).getZkClient());
-      Path confPath = ConfigSetService.getConfigsetPath(confDir, configsetsDirPath.toString());
+      Path confPath =
+          ConfigSetService.getConfigsetPath(params.confDir, configsetsDirPath.toString());
 
       echoIfVerbose(
           "Uploading "
@@ -276,20 +350,19 @@ public class CreateTool extends ToolBase {
     }
 
     // since creating a collection is a heavy-weight operation, check for existence first
-    if (CLIUtils.safeCheckCollectionExists(
-        solrUrl, collectionName, cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION))) {
+    if (CLIUtils.safeCheckCollectionExists(solrUrl, params.name, params.credentials)) {
       throw new IllegalStateException(
           "\nCollection '"
-              + collectionName
+              + params.name
               + "' already exists!\nChecked collection existence using V2 Collections API");
     }
 
     // doesn't seem to exist ... try to create
-    echoIfVerbose("\nCreating new collection '" + collectionName + "' using V2 Collections API");
+    echoIfVerbose("\nCreating new collection '" + params.name + "' using V2 Collections API");
 
     try {
       var req = new CollectionsApi.CreateCollection();
-      req.setName(collectionName);
+      req.setName(params.name);
       req.setConfig(confName);
       req.setNumShards(numShards);
       req.setReplicationFactor(replicationFactor);
@@ -297,14 +370,14 @@ public class CreateTool extends ToolBase {
       echoIfVerbose(response);
     } catch (SolrServerException sse) {
       throw new Exception(
-          "Failed to create collection '" + collectionName + "' due to: " + sse.getMessage());
+          "Failed to create collection '" + params.name + "' due to: " + sse.getMessage());
     }
 
     String endMessage =
         String.format(
             Locale.ROOT,
             "Created collection '%s' with %d shard(s), %d replica(s)",
-            collectionName,
+            params.name,
             numShards,
             replicationFactor);
     if (confName != null && !confName.trim().isEmpty()) {
@@ -329,15 +402,18 @@ public class CreateTool extends ToolBase {
     }
   }
 
-  private void printDefaultConfigsetWarningIfNecessary(CommandLine cli) throws Exception {
-    final String confDirectoryName =
-        cli.getOptionValue(CONF_DIR_OPTION, DefaultValues.DEFAULT_CONFIG_SET);
-    final String confName = cli.getOptionValue(CONF_NAME_OPTION, "");
+  private void printDefaultConfigsetWarning(CreateParams params) {
+    printDefaultConfigsetWarning(
+        params.confDir,
+        params.confName != null ? params.confName : "",
+        params.name,
+        params.solrUrl != null ? params.solrUrl : CLIUtils.getDefaultSolrUrl());
+  }
 
-    if (confDirectoryName.equals("_default")
-        && (confName.isEmpty() || confName.equals("_default"))) {
-      final String collectionName = cli.getOptionValue(COLLECTION_NAME_OPTION);
-      final String solrUrl = CLIUtils.normalizeSolrUrl(cli);
+  private void printDefaultConfigsetWarning(
+      String confDirName, String confNameArg, String collectionName, String solrUrl) {
+    if (confDirName.equals("_default")
+        && (confNameArg.isEmpty() || confNameArg.equals("_default"))) {
       final String curlCommand =
           String.format(
               Locale.ROOT,
@@ -361,4 +437,64 @@ public class CreateTool extends ToolBase {
       echo("            " + configCommand);
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHostArg =
+        (connectionOptions != null)
+            ? connectionOptions.effectiveZkHost()
+            : EnvUtils.getProperty("zkHost");
+    String solrUrlArg = (connectionOptions != null) ? connectionOptions.effectiveSolrUrl() : null;
+
+    if (zkHostArg != null) {
+      CreateParams params =
+          new CreateParams(
+              name,
+              confDir,
+              confName,
+              null,
+              false,
+              credentialsOptions.credentials,
+              shards,
+              replicationFactor);
+      createCollection(zkHostArg, params);
+    } else {
+      String resolvedSolrUrl;
+      if (solrUrlArg != null) {
+        resolvedSolrUrl = CLIUtils.normalizeSolrUrl(solrUrlArg);
+      } else {
+        resolvedSolrUrl = CLIUtils.getDefaultSolrUrl();
+        CLIO.err(
+            "Neither --zk-host or --solr-url parameters, nor ZK_HOST env var provided, so assuming solr url is "
+                + resolvedSolrUrl
+                + ".");
+      }
+      CreateParams params =
+          new CreateParams(
+              name,
+              confDir,
+              confName,
+              resolvedSolrUrl,
+              solrUrlArg != null,
+              credentialsOptions.credentials,
+              shards,
+              replicationFactor);
+      try (var solrClient =
+          CLIUtils.getSolrClient(resolvedSolrUrl, credentialsOptions.credentials)) {
+        Map<String, Object> status = StatusTool.reportStatus(solrClient);
+        @SuppressWarnings("unchecked")
+        Map<String, Object> cloud = (Map<String, Object>) status.get("cloud");
+        if (cloud != null) {
+          String zookeeper = (String) cloud.get("ZooKeeper");
+          if (zookeeper != null && zookeeper.endsWith("(embedded)")) {
+            zookeeper = zookeeper.substring(0, zookeeper.length() - "(embedded)".length());
+          }
+          createCollection(zookeeper, params);
+        } else {
+          createCore(params, solrClient);
+        }
+      }
+    }
+    return 0;
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/java/org/apache/solr/cli/CredentialsOptions.java
similarity index 53%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/java/org/apache/solr/cli/CredentialsOptions.java
index d045ae35501..882792fc4b6 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/java/org/apache/solr/cli/CredentialsOptions.java
@@ -16,24 +16,17 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
-
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
-  }
+/**
+ * Picocli mixin providing the common {@code --credentials} option.
+ *
+ * <p>Use as {@code @CommandLine.Mixin CredentialsOptions credentialsOptions} in a command class.
+ */
+public class CredentialsOptions {
+  @CommandLine.Option(
+      names = {"-u", "--credentials"},
+      description =
+          "Credentials in the format username:password. Example: --credentials solr:SolrRocks")
+  public String credentials;
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/DeleteTool.java b/solr/core/src/java/org/apache/solr/cli/DeleteTool.java
index 5f9c0c6e231..15ca84581f1 100644
--- a/solr/core/src/java/org/apache/solr/cli/DeleteTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/DeleteTool.java
@@ -24,12 +24,32 @@ import org.apache.commons.cli.Options;
 import org.apache.solr.client.solrj.RemoteSolrException;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
 import org.apache.solr.client.solrj.request.CollectionsApi;
 import org.apache.solr.client.solrj.request.ConfigsetsApi;
 import org.apache.solr.client.solrj.request.CoresApi;
 import org.apache.solr.common.SolrException;
+import org.apache.solr.common.util.EnvUtils;
 
 /** Supports delete command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "delete",
+    description =
+        "Deletes a collection or core depending on whether Solr is running in SolrCloud or standalone mode.",
+    exitCodeListHeading = "%nExit Codes:%n",
+    exitCodeList = {
+      "0:Collection or core deleted successfully.",
+      "1:Failed to delete; collection or core may not exist, or Solr may not be running."
+    },
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Delete a collection in SolrCloud mode",
+      "  bin/solr delete -c myCollection",
+      "",
+      "  # Delete and also remove the associated configset",
+      "  bin/solr delete -c myCollection --delete-config"
+    })
 public class DeleteTool extends ToolBase {
 
   private static final Option COLLECTION_NAME_OPTION =
@@ -45,7 +65,7 @@ public class DeleteTool extends ToolBase {
       Option.builder()
           .longOpt("delete-config")
           .desc(
-              "Flag to indicate if the underlying configuration directory for a collection should also be deleted; default is true.")
+              "Flag to indicate if the underlying configuration directory for a collection should also be deleted; default is false.")
           .get();
 
   /**
@@ -68,6 +88,42 @@ public class DeleteTool extends ToolBase {
           .desc("No longer has any effect; retained for backward compatibility.")
           .get();
 
+  /** Options bean shared between commons-cli and picocli paths. */
+  record DeleteParams(String name, boolean deleteConfig) {}
+
+  // --- picocli fields ---
+
+  @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1")
+  private ConnectionOptions connectionOptions;
+
+  @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions;
+
+  @picocli.CommandLine.Option(
+      names = {"-c", "--name"},
+      required = true,
+      description = "Name of the core / collection to delete.")
+  private String name;
+
+  @picocli.CommandLine.Option(
+      names = {"--delete-config"},
+      description =
+          "Flag to indicate if the underlying configuration directory for a collection should also be deleted; default is false.")
+  private boolean deleteConfig;
+
+  /**
+   * @deprecated Since Solr 11.0. See {@link #FORCE_OPTION}.
+   */
+  @Deprecated(since = "11.0")
+  @picocli.CommandLine.Option(
+      names = {"-f", "--force"},
+      description =
+          "Deprecated: no longer has any effect; configset deletion is always safely skipped if the configset is still in use by another collection.")
+  private boolean force;
+
+  public DeleteTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public DeleteTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -99,16 +155,53 @@ public class DeleteTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     try (var solrClient = CLIUtils.getSolrClient(cli)) {
-      if (CLIUtils.isCloudMode(solrClient)) {
-        deleteCollection(cli, solrClient);
-      } else {
-        deleteCore(cli, solrClient);
-      }
+      DeleteParams params =
+          new DeleteParams(
+              cli.getOptionValue(COLLECTION_NAME_OPTION), cli.hasOption(DELETE_CONFIG_OPTION));
+      delete(params, solrClient);
+    }
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHostArg =
+        (connectionOptions != null)
+            ? connectionOptions.effectiveZkHost()
+            : EnvUtils.getProperty("zkHost");
+    String solrUrlArg = (connectionOptions != null) ? connectionOptions.effectiveSolrUrl() : null;
+    String credentials = (credentialsOptions != null) ? credentialsOptions.credentials : null;
+
+    String resolvedSolrUrl;
+    if (solrUrlArg != null) {
+      resolvedSolrUrl = CLIUtils.normalizeSolrUrl(solrUrlArg);
+    } else if (zkHostArg != null) {
+      resolvedSolrUrl =
+          CLIUtils.solrUrlFromConnection(
+              CloudSolrClient.CloudSolrClientConnection.parse(zkHostArg), credentials);
+    } else {
+      resolvedSolrUrl = CLIUtils.getDefaultSolrUrl();
+      CLIO.err(
+          "Neither --zk-host or --solr-url parameters, nor ZK_HOST env var provided, so assuming solr url is "
+              + resolvedSolrUrl
+              + ".");
+    }
+
+    try (var solrClient = CLIUtils.getSolrClient(resolvedSolrUrl, credentials)) {
+      delete(new DeleteParams(name, deleteConfig), solrClient);
+    }
+    return 0;
+  }
+
+  private void delete(DeleteParams params, SolrClient solrClient) throws Exception {
+    if (CLIUtils.isCloudMode(solrClient)) {
+      deleteCollection(params, solrClient);
+    } else {
+      deleteCore(params, solrClient);
     }
   }
 
-  protected void deleteCollection(CommandLine cli, SolrClient solrClient) throws Exception {
-    String collectionName = cli.getOptionValue(COLLECTION_NAME_OPTION);
+  protected void deleteCollection(DeleteParams params, SolrClient solrClient) throws Exception {
+    String collectionName = params.name();
 
     // Scoping the request to this one collection also serves as the existence check below,
     // instead of a separate ListCollections call that would have to scan every collection in
@@ -124,7 +217,6 @@ public class DeleteTool extends ToolBase {
       }
       throw e;
     }
-    boolean deleteConfig = cli.hasOption(DELETE_CONFIG_OPTION);
 
     echoIfVerbose("\nDeleting collection '" + collectionName + "' using V2 Collections API");
 
@@ -137,7 +229,7 @@ public class DeleteTool extends ToolBase {
           "Failed to delete collection '" + collectionName + "' due to: " + sse.getMessage());
     }
 
-    if (deleteConfig && configName != null) {
+    if (params.deleteConfig() && configName != null) {
       try {
         var req = new ConfigsetsApi.DeleteConfigSet(configName);
         req.process(solrClient);
@@ -155,8 +247,8 @@ public class DeleteTool extends ToolBase {
     echo(String.format(Locale.ROOT, "\nDeleted collection '%s'", collectionName));
   }
 
-  protected void deleteCore(CommandLine cli, SolrClient solrClient) throws Exception {
-    String coreName = cli.getOptionValue(COLLECTION_NAME_OPTION);
+  protected void deleteCore(DeleteParams params, SolrClient solrClient) throws Exception {
+    String coreName = params.name();
 
     echo("\nDeleting core '" + coreName + "' using V2 Cores API\n");
 
diff --git a/solr/core/src/java/org/apache/solr/cli/ExportTool.java b/solr/core/src/java/org/apache/solr/cli/ExportTool.java
index 36b0db4fcb0..b63c2ba002d 100644
--- a/solr/core/src/java/org/apache/solr/cli/ExportTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ExportTool.java
@@ -726,4 +726,9 @@ public class ExportTool extends ToolBase {
     SolrDocumentList sdl = (SolrDocumentList) res.get("response");
     return sdl.getNumFound();
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java b/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java
index 11429805028..7311877cc78 100644
--- a/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/HealthcheckTool.java
@@ -210,6 +210,11 @@ public class HealthcheckTool extends ToolBase {
     new JSONWriter(arr, 2).write(report);
     echo(arr.toString());
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
 
 class ReplicaHealth implements Comparable<ReplicaHealth> {
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/java/org/apache/solr/cli/HelpMixin.java
similarity index 53%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/java/org/apache/solr/cli/HelpMixin.java
index d045ae35501..57e2caae50a 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/java/org/apache/solr/cli/HelpMixin.java
@@ -16,24 +16,17 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
-
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
-  }
+/**
+ * Picocli mixin that adds a {@code --help} option to a command without also adding {@code
+ * --version}. Prefer this over {@code mixinStandardHelpOptions = true} on subcommands, since {@code
+ * --version} is only meaningful on the top-level {@code bin/solr} command.
+ */
+public class HelpMixin {
+  @CommandLine.Option(
+      names = {"-h", "--help"},
+      usageHelp = true,
+      description = "Print this help message and exit.")
+  public boolean help;
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/PackageTool.java b/solr/core/src/java/org/apache/solr/cli/PackageTool.java
index 72b1c1ed8c4..cba26c58a3a 100644
--- a/solr/core/src/java/org/apache/solr/cli/PackageTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/PackageTool.java
@@ -412,4 +412,9 @@ public class PackageTool extends ToolBase {
         .addOption(CommonCLIOptions.CREDENTIALS_OPTION)
         .addOptionGroup(getConnectionOptions());
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java b/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java
index 04c0ca745d4..2764ba4b9fb 100644
--- a/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/PostLogsTool.java
@@ -636,4 +636,9 @@ public class PostLogsTool extends ToolBase {
     map.put(ShardRequest.PURPOSE_GET_TERM_STATS, "GET_TERM_STATS");
     purposes = Collections.unmodifiableMap(map);
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/PostTool.java b/solr/core/src/java/org/apache/solr/cli/PostTool.java
index 33449d508c6..8c455cefa3a 100644
--- a/solr/core/src/java/org/apache/solr/cli/PostTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/PostTool.java
@@ -1388,6 +1388,11 @@ public class PostTool extends ToolBase {
     }
   }
 
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
+
   /** Utility class to hold the result form a page fetch */
   public static class PageFetcherResult {
     int httpStatus = 200;
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/java/org/apache/solr/cli/RecursiveOption.java
similarity index 53%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/java/org/apache/solr/cli/RecursiveOption.java
index d045ae35501..d6f9e3590b3 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/java/org/apache/solr/cli/RecursiveOption.java
@@ -16,24 +16,16 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
+/**
+ * Picocli mixin providing the {@code --recursive} option shared by ZooKeeper sub-commands that
+ * support recursive traversal (cp, ls, rm).
+ */
+public class RecursiveOption {
 
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
-  }
+  @CommandLine.Option(
+      names = {"-r", "--recursive"},
+      description = "Apply the command recursively.")
+  public boolean recursive;
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java
index 239a7a16351..e109022e59a 100644
--- a/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/RunExampleTool.java
@@ -1139,6 +1139,11 @@ public class RunExampleTool extends ToolBase {
       throw new IllegalStateException("Required file " + dest.toAbsolutePath() + " not found!");
   }
 
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
+
   protected boolean isPortAvailable(int port) {
     try (Socket s = new Socket("localhost", port)) {
       assert s != null; // To allow compilation.
diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java
index e7abf3ca4a7..89263c9da2c 100644
--- a/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/SnapshotCreateTool.java
@@ -109,4 +109,9 @@ public class SnapshotCreateTool extends ToolBase {
               + e.getLocalizedMessage());
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java
index de912e8e562..7ff35e5e95a 100644
--- a/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/SnapshotDeleteTool.java
@@ -109,4 +109,9 @@ public class SnapshotDeleteTool extends ToolBase {
               + e.getLocalizedMessage());
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java
index e26139f65b7..d945c23d57f 100644
--- a/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/SnapshotDescribeTool.java
@@ -148,4 +148,9 @@ public class SnapshotDescribeTool extends ToolBase {
 
     return result;
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java
index adbaa5b76ef..2a04782d493 100644
--- a/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/SnapshotExportTool.java
@@ -155,4 +155,9 @@ public class SnapshotExportTool extends ToolBase {
               + e.getLocalizedMessage());
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java b/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java
index c1444be5d5d..a4d7ea3b56a 100644
--- a/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/SnapshotListTool.java
@@ -96,4 +96,9 @@ public class SnapshotListTool extends ToolBase {
               + e.getLocalizedMessage());
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java
index 263fb7a7da2..9996742a0b6 100755
--- a/solr/core/src/java/org/apache/solr/cli/SolrCLI.java
+++ b/solr/core/src/java/org/apache/solr/cli/SolrCLI.java
@@ -42,6 +42,7 @@ import org.apache.commons.cli.ParseException;
 import org.apache.commons.cli.help.HelpFormatter;
 import org.apache.commons.cli.help.TableDefinition;
 import org.apache.commons.cli.help.TextHelpAppendable;
+import org.apache.solr.client.api.util.SolrVersion;
 import org.apache.solr.client.solrj.SolrClient;
 import org.apache.solr.client.solrj.request.ContentStreamUpdateRequest;
 import org.apache.solr.common.util.ContentStreamBase;
@@ -53,14 +54,161 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Command-line utility for working with Solr. */
-public class SolrCLI implements CLIO {
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "solr",
+    version = "Client version: " + SolrVersion.LATEST_STRING,
+    synopsisHeading = "usage: bin/",
+    commandListHeading = "\nCommands:\n",
+    footer = {
+      "",
+      "SolrCloud example (embedded Zookeeper):",
+      "",
+      "  ./solr start",
+      "",
+      "Get help for a command by running 'bin/solr COMMAND --help'.",
+      "",
+      "For more help on how to use Solr, head to https://solr.apache.org/"
+    },
+    usageHelpAutoWidth = true,
+    usageHelpWidth = 120,
+    defaultValueProvider = CliDefaultValueProvider.class,
+    subcommands = {
+      StartCommand.class,
+      StopCommand.class,
+      StatusTool.class,
+      VersionTool.class,
+      ZkTool.class,
+      AuthTool.class,
+      CreateTool.class,
+      DeleteTool.class
+    })
+public class SolrCLI implements CLIO, java.util.concurrent.Callable<Integer> {
 
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
+  @picocli.CommandLine.Mixin private HelpMixin helpMixin;
+
+  /**
+   * The commons-cli path maps both {@code -v} and {@code --version} to the version tool, so accept
+   * the same spellings here rather than picocli's {@code -V}-only standard option.
+   */
+  @picocli.CommandLine.Option(
+      names = {"-v", "-V", "--version"},
+      versionHelp = true,
+      description = "Print version information and exit.")
+  private boolean versionRequested;
+
+  @picocli.CommandLine.Spec private picocli.CommandLine.Model.CommandSpec spec;
+
+  /**
+   * Invoked by picocli when {@code bin/solr} is run without a subcommand. Prints usage and exits
+   * non-zero, matching what the commons-cli path does for a missing command.
+   */
+  @Override
+  public Integer call() {
+    spec.commandLine().usage(spec.commandLine().getOut());
+    return 1;
+  }
+
+  @SuppressForbidden(reason = "SolrCLI is a CLI entry point; System.exit is required here")
+  public static void exit(int exitStatus) {
+    try {
+      System.exit(exitStatus);
+    } catch (SecurityException secExc) {
+      if (exitStatus != 0)
+        throw new RuntimeException("SolrCLI failed to exit with status " + exitStatus);
+    }
+  }
+
   /** Runs a tool. */
   public static void main(String[] args) throws Exception {
-    ToolRuntime runtime = new DefaultToolRuntime();
+    if (EnvUtils.getPropertyAsBool("solr.picocli", false)) {
+      SSLConfigurationsFactory.current().init();
+      picocli.CommandLine commandLine = new picocli.CommandLine(new SolrCLI());
+      propagateCommandSettings(commandLine);
+      exit(commandLine.execute(stripEmptyLeadingArg(args)));
+    } else {
+      exit(parseWithCommonsCli(args));
+    }
+  }
+
+  /**
+   * The {@code bin/solr} script invokes the tool with a single empty argument when it wants the
+   * top-level help (see the {@code --help} handling in {@code bin/solr}). picocli would treat that
+   * empty string as an unknown subcommand, so drop it.
+   */
+  @VisibleForTesting
+  static String[] stripEmptyLeadingArg(String[] args) {
+    if (args.length > 0 && args[0].isEmpty()) {
+      return java.util.Arrays.copyOfRange(args, 1, args.length);
+    }
+    return args;
+  }
 
+  /**
+   * Truncates each option's description to its first line for interactive {@code --help} output.
+   * The remaining lines of the {@code description} array are reserved for the generated ref-guide.
+   * This customization is only installed on the interactive CLI command setup, so ref-guide
+   * generation, which runs the doc generator directly, still sees the full description.
+   */
+  @VisibleForTesting
+  static void installFirstLineOnlyHelpFactory(picocli.CommandLine cmd) {
+    cmd.setHelpFactory(
+        (spec, colorScheme) ->
+            new picocli.CommandLine.Help(spec, colorScheme) {
+              @Override
+              public picocli.CommandLine.Help.IOptionRenderer createDefaultOptionRenderer() {
+                picocli.CommandLine.Help.IOptionRenderer base = super.createDefaultOptionRenderer();
+                return (option, paramLabelRenderer, scheme) -> {
+                  picocli.CommandLine.Help.Ansi.Text[][] rows =
+                      base.render(option, paramLabelRenderer, scheme);
+                  return rows.length <= 1
+                      ? rows
+                      : new picocli.CommandLine.Help.Ansi.Text[][] {rows[0]};
+                };
+              }
+            });
+  }
+
+  /** Propagates common settings to all subcommands. */
+  private static void propagateCommandSettings(picocli.CommandLine cmd) {
+    installFirstLineOnlyHelpFactory(cmd);
+    for (picocli.CommandLine subcommand : cmd.getSubcommands().values()) {
+      subcommand.getCommandSpec().defaultValueProvider(cmd.getCommandSpec().defaultValueProvider());
+      subcommand.getCommandSpec().usageMessage().width(cmd.getCommandSpec().usageMessage().width());
+      subcommand
+          .getCommandSpec()
+          .usageMessage()
+          .autoWidth(cmd.getCommandSpec().usageMessage().autoWidth());
+      subcommand
+          .getCommandSpec()
+          .usageMessage()
+          .commandListHeading(cmd.getCommandSpec().usageMessage().commandListHeading());
+      subcommand
+          .getCommandSpec()
+          .usageMessage()
+          .synopsisHeading(cmd.getCommandSpec().usageMessage().synopsisHeading());
+      if (subcommand.getSubcommands().isEmpty()) {
+        subcommand
+            .getCommandSpec()
+            .usageMessage()
+            .footer(
+                "\nFor a full CLI reference, see https://solr.apache.org/guide/solr/latest/deployment-guide/solr-control-script-reference.html");
+      }
+      propagateCommandSettings(subcommand);
+    }
+  }
+
+  /**
+   * Parses the command-line arguments passed by the user using Apache Commons CLI. This
+   *
+   * @param args the original command-line arguments
+   * @deprecated Please use picocli
+   */
+  @Deprecated(since = "10.1")
+  public static int parseWithCommonsCli(String[] args) throws Exception {
+    ToolRuntime runtime = new DefaultToolRuntime();
     final boolean hasNoCommand =
         args == null || args.length == 0 || args[0] == null || args[0].trim().isEmpty();
     final boolean isHelpCommand = !hasNoCommand && Arrays.asList("-h", "--help").contains(args[0]);
@@ -115,7 +263,7 @@ public class SolrCLI implements CLIO {
       runtime.exit(1);
     }
     CommandLine cli = parseCmdLine(tool, args);
-    runtime.exit(tool.runTool(cli));
+    return tool.runTool(cli);
   }
 
   public static Tool findTool(String[] args, ToolRuntime runtime) throws Exception {
@@ -123,6 +271,11 @@ public class SolrCLI implements CLIO {
     return newTool(toolType, runtime);
   }
 
+  /**
+   * @deprecated Part of the commons-cli code path, which picocli replaces. Picocli parses the
+   *     command line itself.
+   */
+  @Deprecated
   public static CommandLine parseCmdLine(Tool tool, String[] args) throws IOException {
     // the parser doesn't like -D props
     List<String> toolArgList = new ArrayList<>();
@@ -138,7 +291,7 @@ public class SolrCLI implements CLIO {
     String[] toolArgs = toolArgList.toArray(new String[0]);
 
     // process command-line args to configure this application
-    CommandLine cli = processCommandLineArgs(tool, toolArgs);
+    org.apache.commons.cli.CommandLine cli = processCommandLineArgs(tool, toolArgs);
 
     List<String> argList = cli.getArgList();
     argList.addAll(dashDList);
@@ -177,6 +330,11 @@ public class SolrCLI implements CLIO {
   }
 
   // Creates an instance of the requested tool, using classpath scanning if necessary
+  /**
+   * @deprecated Part of the commons-cli code path, which picocli replaces. Picocli instantiates
+   *     subcommands itself.
+   */
+  @Deprecated
   private static Tool newTool(String toolType, ToolRuntime runtime) throws Exception {
     if ("healthcheck".equals(toolType)) return new HealthcheckTool(runtime);
     else if ("status".equals(toolType)) return new StatusTool(runtime);
@@ -229,7 +387,7 @@ public class SolrCLI implements CLIO {
    * CLI option.
    */
   public static String getOptionWithDeprecatedAndDefault(
-      CommandLine cli, Option opt, Option deprecated, String def) {
+      org.apache.commons.cli.CommandLine cli, Option opt, Option deprecated, String def) {
     String val = cli.getOptionValue(opt);
     if (val == null) {
       val = cli.getOptionValue(deprecated);
@@ -239,6 +397,10 @@ public class SolrCLI implements CLIO {
 
   // TODO: SOLR-17429 - remove the custom logic when Commons CLI is upgraded and
   // makes stderr the default, or makes Option.toDeprecatedString() public.
+  /**
+   * @deprecated Part of the commons-cli code path, which picocli replaces.
+   */
+  @Deprecated
   private static void deprecatedHandlerStdErr(Option o) {
     // Deprecated options without a description act as "stealth" options
     if (o.isDeprecated() && !o.getDeprecated().getDescription().isBlank()) {
@@ -252,12 +414,18 @@ public class SolrCLI implements CLIO {
     }
   }
 
-  /** Parses the command-line arguments passed by the user. */
+  /**
+   * Parses the command-line arguments passed by the user.
+   *
+   * @deprecated Part of the commons-cli code path, which picocli replaces. Picocli parses the
+   *     command line itself.
+   */
+  @Deprecated
   public static CommandLine processCommandLineArgs(Tool tool, String[] args) throws IOException {
     Options options = tool.getOptions();
     ToolRuntime runtime = tool.getRuntime();
 
-    CommandLine cli = null;
+    org.apache.commons.cli.CommandLine cli = null;
     try {
       cli =
           DefaultParser.builder()
@@ -293,7 +461,13 @@ public class SolrCLI implements CLIO {
     return cli;
   }
 
-  /** Prints tool help for a given tool */
+  /**
+   * Prints tool help for a given tool.
+   *
+   * @deprecated Part of the commons-cli code path, which picocli replaces. Picocli renders usage
+   *     help from the command's annotations.
+   */
+  @Deprecated
   public static void printToolHelp(Tool tool) throws IOException {
     HelpFormatter formatter = getFormatter();
     Options nonDeprecatedOptions = new Options();
@@ -312,6 +486,11 @@ public class SolrCLI implements CLIO {
         autoGenerateUsage);
   }
 
+  /**
+   * @deprecated Part of the commons-cli code path, which picocli replaces. Picocli renders usage
+   *     help from the command's annotations.
+   */
+  @Deprecated
   @SuppressForbidden(reason = "System.out for formatting")
   public static HelpFormatter getFormatter() {
     TextHelpAppendable helpAppendable =
@@ -347,7 +526,13 @@ public class SolrCLI implements CLIO {
     return formatter;
   }
 
-  /** Scans Jar files on the classpath for Tool implementations to activate. */
+  /**
+   * Scans Jar files on the classpath for Tool implementations to activate.
+   *
+   * @deprecated Picocli resolves subcommands from the {@code @Command(subcommands = ...)}
+   *     declaration on {@link SolrCLI}, so no classpath scanning is needed.
+   */
+  @Deprecated
   private static List<Class<? extends Tool>> findToolClassesInPackage(String packageName) {
     List<Class<? extends Tool>> toolClasses = new ArrayList<>();
     try {
@@ -371,6 +556,10 @@ public class SolrCLI implements CLIO {
     return toolClasses;
   }
 
+  /**
+   * @deprecated Part of the commons-cli code path, which picocli replaces.
+   */
+  @Deprecated
   private static Set<String> findClasses(String path, String packageName) throws Exception {
     Set<String> classes = new TreeSet<>();
     if (path.startsWith("file:") && path.contains("!")) {
@@ -429,6 +618,11 @@ public class SolrCLI implements CLIO {
         numSeconds);
   }
 
+  /**
+   * @deprecated Part of the commons-cli code path, which picocli replaces. Picocli renders the
+   *     top-level usage help from annotations.
+   */
+  @Deprecated
   private static void printHelp() {
 
     print("Usage: solr COMMAND OPTIONS");
diff --git a/solr/core/src/java/org/apache/solr/cli/StartCommand.java b/solr/core/src/java/org/apache/solr/cli/StartCommand.java
new file mode 100644
index 00000000000..06a747b361f
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cli/StartCommand.java
@@ -0,0 +1,247 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+    name = "start",
+    description = "Starts Solr",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Start Solr in SolrCloud mode on the default port",
+      "  bin/solr start",
+      "",
+      "  # Start on a custom port with 2g heap",
+      "  bin/solr start -p 8984 -m 2g",
+      "",
+      "  # Start in user managed mode",
+      "  bin/solr start --user-managed"
+    })
+public class StartCommand implements Callable<Integer> {
+
+  @CommandLine.Mixin HelpMixin helpMixin;
+
+  @CommandLine.Spec CommandLine.Model.CommandSpec spec;
+
+  @CommandLine.Option(
+      names = {"-f", "--foreground"},
+      description =
+          "Start Solr in foreground; default is background with logs to solr-PORT-console.log")
+  boolean foreground;
+
+  @CommandLine.Option(
+      names = "--user-managed",
+      description = "Start Solr in standalone mode. Default is SolrCloud (ZooKeeper) mode.")
+  boolean userManaged;
+
+  @CommandLine.Option(
+      names = "--host",
+      description =
+          "Specify the hostname this Solr node will advertise to ZooKeeper and other cluster members (SOLR_HOST_ADVERTISE)."
+              + " Defaults to the canonical hostname of the local host.")
+  String host;
+
+  @CommandLine.Option(
+      names = {"-p", "--port"},
+      description =
+          "Specify the Solr HTTP port; default is 8983. STOP_PORT=($SOLR_PORT-1000), RMI_PORT=($SOLR_PORT+10000)")
+  String port;
+
+  @CommandLine.Option(
+      names = "--server-dir",
+      description = "Specify the Solr server directory; default is 'server'")
+  String serverDir;
+
+  @CommandLine.Option(
+      names = {"-z", "--zk-host"},
+      description =
+          "Zookeeper connection string; default is to start an embedded ZooKeeper on PORT+10000")
+  String zkHost;
+
+  @CommandLine.Option(
+      names = {"-m", "--memory"},
+      description = "Set JVM heap size, e.g., -m 4g sets -Xms4g -Xmx4g; default is 512m")
+  String memory;
+
+  @CommandLine.Option(
+      names = "--solr-home",
+      description =
+          "Set solr.solr.home system property; default is 'server/solr'. Ignored in examples mode")
+  String solrHome;
+
+  @CommandLine.Option(
+      names = "--data-home",
+      description =
+          "Set solr.data.home system property for index data storage; default is solr.solr.home")
+  String dataHome;
+
+  @CommandLine.Option(
+      names = {"-e", "--example"},
+      description = "Run an example: cloud, techproducts, schemaless, films")
+  String example;
+
+  @CommandLine.Option(
+      names = "--jvm-opts",
+      description = "Additional JVM parameters, e.g., --jvm-opts \"-verbose:gc\"")
+  String jvmOpts;
+
+  @CommandLine.Option(
+      names = {"-j", "--jettyconfig"},
+      description =
+          "Additional Jetty parameters, e.g., -j \"--include-jetty-dir=/etc/jetty/custom/server/\"")
+  String jettyParams;
+
+  @CommandLine.Option(
+      names = {"-y", "--no-prompt"},
+      description = "Don't prompt for input; accept all defaults when running examples")
+  boolean noPrompt;
+
+  @CommandLine.Option(
+      names = "--prompt-inputs",
+      description =
+          "Don't prompt for input; comma-delimited list of inputs to use when running examples that accept user input",
+      paramLabel = "<values>")
+  String promptInputs;
+
+  @CommandLine.Option(
+      names = "--example-dir",
+      description =
+          "Override the directory containing example configurations used when running examples with --example")
+  String exampleDir;
+
+  @CommandLine.Option(
+      names = "--force",
+      description = "Override warning when attempting to start Solr as root user")
+  boolean force;
+
+  @CommandLine.Option(
+      names = {"--verbose"},
+      description = "Set log level to DEBUG (verbose); default is INFO")
+  boolean verbose;
+
+  @CommandLine.Option(
+      names = {"--quiet", "-q"},
+      description = "Set log level to WARN (quiet); default is INFO")
+  boolean quiet;
+
+  // TODO: SOLR-17697 remove --fullhelp when commons-cli is removed; picocli's --help already
+  // produces equivalent output from the annotations.
+  @CommandLine.Option(
+      names = "--fullhelp",
+      description = "Print detailed help with full option descriptions",
+      hidden = true)
+  boolean fullhelp;
+
+  @Override
+  public Integer call() {
+    if (fullhelp) {
+      printFullHelp();
+    }
+    // Actual start logic is handled by the bin/solr shell script.
+    return 0;
+  }
+
+  // TODO: Remove when removing commons-cli
+  private void printFullHelp() {
+    CLIO.out(
+        """
+        Usage: solr start [OPTIONS]
+
+        Starts Solr in standalone or SolrCloud mode.
+
+        Options:
+          -f, --foreground
+              Start Solr in foreground; default starts Solr in the background and sends
+              stdout / stderr to solr-PORT-console.log
+
+          --user-managed
+              Start Solr in user managed aka standalone mode.
+              See: https://solr.apache.org/guide/solr/latest/deployment-guide/cluster-types.html
+
+          --host <hostname>
+              Specify the hostname for this Solr instance
+
+          -p, --port <port>
+              Specify the port to start the Solr HTTP listener on; default is 8983.
+              The specified port (SOLR_PORT) will also be used to determine the stop port:
+              STOP_PORT=($SOLR_PORT-1000) and JMX RMI listen port RMI_PORT=($SOLR_PORT+10000).
+              For instance, if you set -p 8985, then STOP_PORT=7985 and RMI_PORT=18985
+
+          --server-dir <dir>
+              Specify the Solr server directory; defaults to server
+
+          -z, --zk-host <zkHost>
+              Zookeeper connection string; ignored in User Managed (--user-managed) mode.
+              If neither ZK_HOST is defined in solr.in.sh nor -z is specified, an embedded
+              ZooKeeper instance will be launched.
+              Set ZK_CREATE_CHROOT=true if your ZK host has a chroot path to create it automatically.
+
+          -m, --memory <size>
+              Sets the min (-Xms) and max (-Xmx) heap size for the JVM, e.g., -m 4g sets
+              -Xms4g -Xmx4g; by default, this script sets the heap size to 512m
+
+          --solr-home <dir>
+              Sets the solr.solr.home system property; Solr will create core directories here.
+              Allows running multiple Solr instances on the same host while reusing the same
+              server directory. If set, the directory should contain a solr.xml file unless
+              solr.xml exists in ZooKeeper. Ignored when running examples (-e).
+              Default: server/solr
+
+          --data-home <dir>
+              Sets the solr.data.home system property, where Solr stores index data in
+              <instance_dir>/data subdirectories. If not set, Solr uses solr.solr.home.
+
+          -e, --example <name>
+              Name of the example to run; available examples:
+                cloud:          SolrCloud example
+                techproducts:   Comprehensive example illustrating many of Solr's core capabilities
+                schemaless:     Schema-less example (schema inferred from data during indexing)
+                films:          Example starting with _default configset with explicit fields
+
+          --jvm-opts <jvmParams>
+              Additional parameters to pass to the JVM when starting Solr, e.g., to enable
+              a Java debugger: --jvm-opts "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=18983"
+              Wrap additional parameters in double quotes.
+
+          -j, --jettyconfig <jettyParams>
+              Additional parameters to pass to Jetty, e.g., to add a config folder:
+              -j "--include-jetty-dir=/etc/jetty/custom/server/"
+              Wrap additional parameters in double quotes.
+
+          -y, --no-prompt
+              Don't prompt for input; accept all defaults when running examples.
+
+          --prompt-inputs <values>
+              Don't prompt for input; comma-delimited list of inputs for examples that
+              accept user input.
+
+          --example-dir <dir>
+              Override the directory containing example configurations.
+
+          --force
+              Override warning when attempting to start Solr as the root user.
+
+          --verbose
+              Set log level to DEBUG (verbose); default is INFO
+
+          -q, --quiet
+              Set log level to WARN (quiet); default is INFO
+        """);
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/cli/StatusTool.java b/solr/core/src/java/org/apache/solr/cli/StatusTool.java
index 27a4840a3a4..a51e1ba7adc 100644
--- a/solr/core/src/java/org/apache/solr/cli/StatusTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/StatusTool.java
@@ -31,6 +31,7 @@ import org.apache.commons.cli.OptionGroup;
 import org.apache.commons.cli.Options;
 import org.apache.solr.cli.SolrProcessManager.SolrProcess;
 import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
 import org.apache.solr.client.solrj.request.ClusterApi;
 import org.apache.solr.client.solrj.request.CollectionsApi;
 import org.apache.solr.client.solrj.request.SystemInfoRequest;
@@ -44,8 +45,86 @@ import org.noggit.JSONWriter;
  *
  * <p>Get the status of a Solr server.
  */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "status",
+    description = "Get the status of a Solr server.",
+    exitCodeListHeading = "%nExit Codes:%n",
+    exitCodeList = {
+      "0:Solr is running.",
+      "1:Solr is not running, or the URL/port could not be reached."
+    },
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Check status of Solr running on the default port",
+      "  bin/solr status",
+      "",
+      "  # Check status of a specific Solr instance by URL",
+      "  bin/solr status -s http://localhost:8983",
+      "",
+      "  # Wait up to 30 seconds for Solr to start",
+      "  bin/solr status --max-wait-secs 30 -s http://localhost:8983"
+    })
 public class StatusTool extends ToolBase {
+  @picocli.CommandLine.Option(
+      names = {"--max-wait-secs"},
+      defaultValue = "0",
+      description = "Wait up to the specified number of seconds to see Solr running.")
+  private Integer maxWaitSecs;
 
+  @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1")
+  private TargetOptions targetOptions;
+
+  static class TargetOptions {
+    @picocli.CommandLine.Option(
+        names = {"-p", "--port"},
+        description = "Port on localhost to check status for")
+    private Integer port;
+
+    @picocli.CommandLine.Option(
+        names = {"-s", "--solr-connection"},
+        description =
+            "Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to "
+                + CommonCLIOptions.DefaultValues.ZK_HOST
+                + '.')
+    private String solrConnection;
+
+    @picocli.CommandLine.Option(
+        names = {"--solr-url"},
+        description =
+            "Base Solr URL, which can be used to determine the zk-host if that's not known")
+    private String solrUrl;
+
+    @picocli.CommandLine.Option(
+        names = {"-z", "--zk-host"},
+        description =
+            "Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to "
+                + CommonCLIOptions.DefaultValues.ZK_HOST
+                + '.')
+    private String zkHost;
+  }
+
+  private Integer port;
+
+  private String solrUrl;
+
+  @picocli.CommandLine.Option(
+      names = {"--short"},
+      paramLabel = "short",
+      description = "Short format. Prints one URL per line for running instances")
+  private boolean shortFormat;
+
+  @picocli.CommandLine.Option(
+      names = {"-u", "--credentials"},
+      description =
+          "Credentials in the format username:password. Example: --credentials solr:SolrRocks")
+  private String credentials;
+
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   private static final Option MAX_WAIT_SECS_OPTION =
       Option.builder()
           .longOpt("max-wait-secs")
@@ -56,6 +135,11 @@ public class StatusTool extends ToolBase {
           .desc("Wait up to the specified number of seconds to see Solr running.")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   public static final Option PORT_OPTION =
       Option.builder("p")
           .longOpt("port")
@@ -65,6 +149,11 @@ public class StatusTool extends ToolBase {
           .desc("Port on localhost to check status for")
           .get();
 
+  /**
+   * @deprecated Only used by the commons-cli parser; the picocli path declares this as an annotated
+   *     field.
+   */
+  @Deprecated
   public static final Option SHORT_OPTION =
       Option.builder()
           .longOpt("short")
@@ -74,6 +163,10 @@ public class StatusTool extends ToolBase {
 
   private final SolrProcessManager processMgr;
 
+  public StatusTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public StatusTool(ToolRuntime runtime) {
     super(runtime);
     processMgr = new SolrProcessManager();
@@ -100,11 +193,16 @@ public class StatusTool extends ToolBase {
 
   @Override
   public void runImpl(CommandLine cli) throws Exception {
-    String solrUrl = CLIUtils.hasConnectionOption(cli) ? CLIUtils.normalizeSolrUrl(cli) : null;
-    Integer port = cli.hasOption(PORT_OPTION) ? cli.getParsedOptionValue(PORT_OPTION) : null;
-    boolean shortFormat = cli.hasOption(SHORT_OPTION);
-    int maxWaitSecs = cli.getParsedOptionValue(MAX_WAIT_SECS_OPTION, 0);
+    solrUrl = CLIUtils.hasConnectionOption(cli) ? CLIUtils.normalizeSolrUrl(cli) : null;
+    port = cli.hasOption(PORT_OPTION) ? cli.getParsedOptionValue(PORT_OPTION) : null;
+    shortFormat = cli.hasOption(SHORT_OPTION);
+    maxWaitSecs = cli.getParsedOptionValue(MAX_WAIT_SECS_OPTION, 0);
+    credentials = cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION);
+
+    runTool();
+  }
 
+  public int runTool() throws Exception {
     if (solrUrl != null) {
       if (!URLUtil.hasScheme(solrUrl)) {
         CLIO.err("Invalid URL provided: " + solrUrl);
@@ -115,14 +213,14 @@ public class StatusTool extends ToolBase {
       if (maxWaitSecs > 0) {
         // Used by Windows start script when starting Solr
         try {
-          waitForSolrUpAndPrintStatus(solrUrl, cli, maxWaitSecs);
+          waitForSolrUpAndPrintStatus(solrUrl);
           runtime.exit(0);
         } catch (Exception e) {
           CLIO.err(e.getMessage());
           runtime.exit(1);
         }
       } else {
-        boolean running = printStatusFromRunningSolr(solrUrl, cli);
+        boolean running = printStatusFromRunningSolr(solrUrl);
         runtime.exit(running ? 0 : 1);
       }
     }
@@ -137,7 +235,7 @@ public class StatusTool extends ToolBase {
         if (shortFormat) {
           CLIO.out(solrUrl);
         } else {
-          printProcessStatus(proc.get(), cli);
+          printProcessStatus(proc.get());
         }
         runtime.exit(0);
       }
@@ -150,7 +248,7 @@ public class StatusTool extends ToolBase {
         if (shortFormat) {
           CLIO.out(process.getLocalUrl());
         } else {
-          printProcessStatus(process, cli);
+          printProcessStatus(process);
         }
       }
     } else {
@@ -158,17 +256,16 @@ public class StatusTool extends ToolBase {
         CLIO.out("\nNo Solr nodes are running.\n");
       }
     }
+    return 0;
   }
 
-  private void printProcessStatus(SolrProcess process, CommandLine cli) throws Exception {
-    int maxWaitSecs = cli.getParsedOptionValue(MAX_WAIT_SECS_OPTION, 0);
-    boolean shortFormat = cli.hasOption(SHORT_OPTION);
+  private void printProcessStatus(SolrProcess process) throws Exception {
     String pidUrl = process.getLocalUrl();
     if (shortFormat) {
       CLIO.out(pidUrl);
     } else {
       if (maxWaitSecs > 0) {
-        waitForSolrUpAndPrintStatus(pidUrl, cli, maxWaitSecs);
+        waitForSolrUpAndPrintStatus(pidUrl);
       } else {
         CLIO.out(
             String.format(
@@ -176,23 +273,22 @@ public class StatusTool extends ToolBase {
                 "\nSolr process %s running on port %s",
                 process.pid(),
                 process.port()));
-        printStatusFromRunningSolr(pidUrl, cli);
+        printStatusFromRunningSolr(pidUrl);
       }
     }
     CLIO.out("");
   }
 
-  public void waitForSolrUpAndPrintStatus(String solrUrl, CommandLine cli, int maxWaitSecs)
-      throws Exception {
+  public void waitForSolrUpAndPrintStatus(String pidUrl) throws Exception {
     int solrPort = -1;
     try {
-      solrPort = CLIUtils.portFromUrl(solrUrl);
+      solrPort = CLIUtils.portFromUrl(pidUrl);
     } catch (Exception e) {
       CLIO.err("Invalid URL provided, does not contain port");
       runtime.exit(1);
     }
     echo("Waiting up to " + maxWaitSecs + " seconds to see Solr running on port " + solrPort);
-    boolean solrUp = waitForSolrUp(solrUrl, cli, maxWaitSecs);
+    boolean solrUp = waitForSolrUp(pidUrl);
     if (solrUp) {
       echo("Started Solr server on port " + solrPort + ". Happy searching!");
     } else {
@@ -204,35 +300,28 @@ public class StatusTool extends ToolBase {
   /**
    * Wait for Solr to come online and return true if it does, false otherwise.
    *
-   * @param solrUrl the URL of the Solr server
-   * @param cli the command line options
-   * @param maxWaitSecs the maximum number of seconds to wait
    * @return true if Solr comes online, false otherwise
    */
-  public boolean waitForSolrUp(String solrUrl, CommandLine cli, int maxWaitSecs) throws Exception {
+  public boolean waitForSolrUp(String pidUrl) throws Exception {
     try {
-      waitToSeeSolrUp(
-          solrUrl,
-          cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION),
-          maxWaitSecs,
-          TimeUnit.SECONDS);
+      waitToSeeSolrUp(pidUrl, credentials, maxWaitSecs, TimeUnit.SECONDS);
       return true;
     } catch (TimeoutException timeout) {
       return false;
     }
   }
 
-  public boolean printStatusFromRunningSolr(String solrUrl, CommandLine cli) {
+  public boolean printStatusFromRunningSolr(String pidUrl) {
     String statusJson = null;
     try {
-      statusJson = statusFromRunningSolr(solrUrl, cli);
+      statusJson = statusFromRunningSolr(pidUrl);
     } catch (Exception e) {
       /* ignore */
     }
     if (statusJson != null) {
       runtime.println(statusJson);
     } else {
-      CLIO.err("Solr at " + solrUrl + " not online.");
+      CLIO.err("Solr at " + pidUrl + " not online.");
     }
     return statusJson != null;
   }
@@ -240,16 +329,13 @@ public class StatusTool extends ToolBase {
   /**
    * Get the status of a Solr server and responds with a JSON status string.
    *
-   * @param solrUrl the URL of the Solr server
-   * @param cli the command line options
    * @return the status of the Solr server or null if the server is not online
    * @throws Exception if there is an error getting the status
    */
-  public String statusFromRunningSolr(String solrUrl, CommandLine cli) throws Exception {
+  public String statusFromRunningSolr(String pidUrl) throws Exception {
     try {
       CharArr arr = new CharArr();
-      new JSONWriter(arr, 2)
-          .write(getStatus(solrUrl, cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION)));
+      new JSONWriter(arr, 2).write(getStatus(pidUrl));
       return arr.toString();
     } catch (Exception exc) {
       if (CLIUtils.exceptionIsAuthRelated(exc)) {
@@ -259,19 +345,23 @@ public class StatusTool extends ToolBase {
         // this is not actually an error from the tool as it's ok if Solr is not online.
         return null;
       } else {
-        throw new Exception("Failed to get system information from " + solrUrl + " due to: " + exc);
+        throw new Exception("Failed to get system information from " + pidUrl + " due to: " + exc);
       }
     }
   }
 
+  public Map<String, Object> waitToSeeSolrUp(String pidUrl) throws Exception {
+    return waitToSeeSolrUp(pidUrl, credentials, maxWaitSecs, TimeUnit.SECONDS);
+  }
+
   @SuppressWarnings("BusyWait")
   public Map<String, Object> waitToSeeSolrUp(
-      String solrUrl, String credentials, long maxWait, TimeUnit unit) throws Exception {
+      String pidUrl, String credentials, long maxWait, TimeUnit unit) throws Exception {
     long timeout = System.nanoTime() + TimeUnit.NANOSECONDS.convert(maxWait, unit);
     while (System.nanoTime() < timeout) {
 
       try {
-        return getStatus(solrUrl, credentials);
+        return getStatus(pidUrl, credentials);
       } catch (Exception exc) {
         if (CLIUtils.exceptionIsAuthRelated(exc)) {
           throw exc;
@@ -291,8 +381,12 @@ public class StatusTool extends ToolBase {
             + " seconds!");
   }
 
-  public Map<String, Object> getStatus(String solrUrl, String credentials) throws Exception {
-    try (var solrClient = CLIUtils.getSolrClient(solrUrl, credentials)) {
+  public Map<String, Object> getStatus(String pidUrl) throws Exception {
+    return getStatus(pidUrl, credentials);
+  }
+
+  public Map<String, Object> getStatus(String pidUrl, String credentials) throws Exception {
+    try (var solrClient = CLIUtils.getSolrClient(pidUrl, credentials)) {
       return reportStatus(solrClient);
     }
   }
@@ -342,4 +436,25 @@ public class StatusTool extends ToolBase {
 
     return cloudStatus;
   }
+
+  @Override
+  public int callTool() throws Exception {
+    if (targetOptions != null) {
+      port = targetOptions.port;
+      if (targetOptions.solrUrl != null) {
+        solrUrl = targetOptions.solrUrl;
+      } else {
+        String connectionString =
+            targetOptions.solrConnection != null
+                ? targetOptions.solrConnection
+                : targetOptions.zkHost;
+        if (connectionString != null) {
+          solrUrl =
+              CLIUtils.solrUrlFromConnection(
+                  CloudSolrClient.CloudSolrClientConnection.parse(connectionString), credentials);
+        }
+      }
+    }
+    return runTool();
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/StopCommand.java b/solr/core/src/java/org/apache/solr/cli/StopCommand.java
new file mode 100644
index 00000000000..b69d7771f0b
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cli/StopCommand.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import picocli.CommandLine;
+
+/**
+ * This class is currently only used for printing CLI usage. The stop logic is currently handled in
+ * start script.
+ */
+@CommandLine.Command(
+    name = "stop",
+    description = "Stops Solr.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Stop Solr running on the default port",
+      "  bin/solr stop -p 8983",
+      "",
+      "  # Stop all running Solr instances on this host",
+      "  bin/solr stop --all"
+    })
+public class StopCommand {
+
+  @CommandLine.Mixin HelpMixin helpMixin;
+
+  @CommandLine.Option(
+      names = {"-p", "--port"},
+      description =
+          "Specify the port the Solr HTTP listener is bound to.\n"
+              + "The STOP_PORT is derived as ($SOLR_PORT-1000).")
+  String port;
+
+  @CommandLine.Option(
+      names = {"-k", "--key"},
+      description = "Stop key; default is solrrocks",
+      defaultValue = "solrrocks")
+  String key;
+
+  @CommandLine.Option(
+      names = "--all",
+      description = "Find and stop all running Solr servers on this host")
+  boolean all;
+
+  @CommandLine.Option(
+      names = {"--verbose"},
+      description = "Enable verbose mode.")
+  boolean verbose;
+}
diff --git a/solr/core/src/java/org/apache/solr/cli/StreamTool.java b/solr/core/src/java/org/apache/solr/cli/StreamTool.java
index 8ff7aa5a197..da8beec6657 100644
--- a/solr/core/src/java/org/apache/solr/cli/StreamTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/StreamTool.java
@@ -532,6 +532,11 @@ public class StreamTool extends ToolBase {
     return buf.toString();
   }
 
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
+
   static String readExpression(LineNumberReader bufferedReader, String[] args) throws IOException {
 
     StringBuilder exprBuff = new StringBuilder();
diff --git a/solr/core/src/java/org/apache/solr/cli/Tool.java b/solr/core/src/java/org/apache/solr/cli/Tool.java
index 1ad58bce50e..595dcf88f56 100644
--- a/solr/core/src/java/org/apache/solr/cli/Tool.java
+++ b/solr/core/src/java/org/apache/solr/cli/Tool.java
@@ -21,7 +21,13 @@ import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
 
 public interface Tool {
-  /** Defines the interface to a Solr tool that can be run from this command-line app. */
+  /**
+   * Defines the interface to a Solr tool that can be run from this command-line app.
+   *
+   * @deprecated Only used by the commons-cli parser. Tools built on picocli take their name from
+   *     the {@code @Command} annotation instead.
+   */
+  @Deprecated
   String getName();
 
   /**
@@ -29,14 +35,20 @@ public interface Tool {
    * Override for custom string
    *
    * @return The custom usage string or 'null' to auto generate (default)
+   * @deprecated Only used by the commons-cli parser. Picocli generates the synopsis from the
+   *     command's annotations.
    */
+  @Deprecated
   default String getUsage() {
     return null;
   }
 
   /**
    * Optional header to display before the options in help output. Defaults to 'List of options:'
+   *
+   * @deprecated Only used by the commons-cli parser. Picocli uses {@code @Command(header = ...)}.
    */
+  @Deprecated
   default String getHeader() {
     return "List of options:";
   }
@@ -44,7 +56,10 @@ public interface Tool {
   /**
    * Optional footer to display after the options in help output. Defaults to a link to reference
    * guide
+   *
+   * @deprecated Only used by the commons-cli parser. Picocli uses {@code @Command(footer = ...)}.
    */
+  @Deprecated
   default String getFooter() {
     return "\nPlease see the Reference Guide for more tools documentation: https://solr.apache.org/guide/solr/latest/deployment-guide/solr-control-script-reference.html";
   }
@@ -56,8 +71,17 @@ public interface Tool {
    * Retrieve the {@link Options} supported by this tool.
    *
    * @return The {@link Options} this tool supports.
+   * @deprecated Only used by the commons-cli parser. Picocli tools declare their options as
+   *     annotated fields instead.
    */
+  @Deprecated
   Options getOptions();
 
+  /**
+   * Runs the tool against an already-parsed commons-cli command line.
+   *
+   * @deprecated Implement {@link ToolBase#callTool()} instead, which picocli invokes.
+   */
+  @Deprecated
   int runTool(CommandLine cli) throws Exception;
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ToolBase.java b/solr/core/src/java/org/apache/solr/cli/ToolBase.java
index f06a6cfd088..cba5e33464b 100644
--- a/solr/core/src/java/org/apache/solr/cli/ToolBase.java
+++ b/solr/core/src/java/org/apache/solr/cli/ToolBase.java
@@ -18,18 +18,32 @@
 package org.apache.solr.cli;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
-import org.apache.commons.cli.CommandLine;
+import java.util.concurrent.Callable;
 import org.apache.commons.cli.OptionGroup;
 import org.apache.commons.cli.Options;
 import org.apache.solr.client.solrj.request.json.JacksonContentWriter;
 import org.apache.solr.util.StartupLoggingUtils;
+import picocli.CommandLine;
 
-public abstract class ToolBase implements Tool {
-
-  protected final ToolRuntime runtime;
+/**
+ * Base class for {@code bin/solr} tools.
+ *
+ * <p>Two command line parsers are supported while the migration from commons-cli to picocli is in
+ * progress: {@link #runImpl} serves the commons-cli path and {@link #callTool()} serves the picocli
+ * path. {@link #callTool()} is deliberately abstract so that every tool has to state which of the
+ * two it supports; tools that have not been ported yet throw {@link UnsupportedOperationException}
+ * from it, and are not registered as picocli subcommands.
+ */
+public abstract class ToolBase implements Tool, Callable<Integer> {
+  @CommandLine.Mixin private HelpMixin helpMixin;
 
+  @CommandLine.Option(
+      names = {"-v", "--verbose"},
+      description = "Enable verbose mode.")
   private boolean verbose = false;
 
+  protected final ToolRuntime runtime;
+
   protected ToolBase(ToolRuntime runtime) {
     this.runtime = runtime;
   }
@@ -75,7 +89,10 @@ public abstract class ToolBase implements Tool {
    * unified {@code --solr-connection} (which accepts either form).
    *
    * @return OptionGroup that enforces only one of the connection options is supplied.
+   * @deprecated Only used by the commons-cli parser. Picocli tools mix in {@code ConnectionOptions}
+   *     instead.
    */
+  @Deprecated
   public OptionGroup getConnectionOptions() {
     OptionGroup optionGroup = new OptionGroup();
     optionGroup.addOption(CommonCLIOptions.SOLR_URL_OPTION);
@@ -85,7 +102,7 @@ public abstract class ToolBase implements Tool {
   }
 
   @Override
-  public int runTool(CommandLine cli) throws Exception {
+  public int runTool(org.apache.commons.cli.CommandLine cli) throws Exception {
     verbose = cli.hasOption(CommonCLIOptions.VERBOSE_OPTION);
     raiseLogLevelUnlessVerbose();
 
@@ -114,5 +131,42 @@ public abstract class ToolBase implements Tool {
     }
   }
 
-  public abstract void runImpl(CommandLine cli) throws Exception;
+  /**
+   * @deprecated Implement {@link #callTool()} instead, which picocli invokes.
+   */
+  @Deprecated
+  public abstract void runImpl(org.apache.commons.cli.CommandLine cli) throws Exception;
+
+  /**
+   * Called by picocli to execute the tool's logic.
+   *
+   * <p>Every tool must implement this. A tool that has not been ported to picocli yet should throw
+   * {@link UnsupportedOperationException}, and must not be listed in {@link SolrCLI}'s {@code
+   * subcommands}.
+   */
+  public abstract int callTool() throws Exception;
+
+  /** Called by picocli for a tool invocation. Delegates to {@link #callTool()}. */
+  @Override
+  public Integer call() {
+    raiseLogLevelUnlessVerbose();
+
+    int toolExitStatus = 0;
+    try {
+      toolExitStatus = callTool();
+    } catch (Exception exc) {
+      // since this is a CLI, spare the user the stacktrace
+      String excMsg = exc.getMessage();
+      if (excMsg != null) {
+        CLIO.err("\nERROR: " + excMsg + "\n");
+        if (verbose) {
+          exc.printStackTrace(CLIO.getErrStream());
+        }
+        toolExitStatus = 1;
+      } else {
+        throw new RuntimeException(exc);
+      }
+    }
+    return toolExitStatus;
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/UpdateACLTool.java b/solr/core/src/java/org/apache/solr/cli/UpdateACLTool.java
index 021f05d7e82..4a1e6cf331d 100644
--- a/solr/core/src/java/org/apache/solr/cli/UpdateACLTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/UpdateACLTool.java
@@ -29,9 +29,37 @@ import org.apache.solr.common.cloud.SolrZkClient;
  *
  * <p>Set ACL properties by directly manipulating ZooKeeper.
  */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "updateacls",
+    description =
+        "Recursively re-applies ZooKeeper ACLs to a znode and all its descendants."
+            + " The ACLs are determined by the ZkACLProvider configured via the zkACLProvider"
+            + " system property (pass via SOLR_TOOL_OPTS). Without a custom provider, the"
+            + " default OPEN_ACL_UNSAFE ACLs are applied.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Apply ACLs to the root Solr znode",
+      "  bin/solr zk updateacls /solr -z localhost:9983",
+      "",
+      "  # Apply ACLs to a specific configset",
+      "  bin/solr zk updateacls /configs/myconfig -z localhost:9983"
+    })
 public class UpdateACLTool extends ToolBase {
   // It is a shame this tool doesn't more closely mimic how the ConfigTool works.
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      description = "The ZooKeeper znode path to update ACLs for.")
+  private String path;
+
+  public UpdateACLTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public UpdateACLTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -53,7 +81,6 @@ public class UpdateACLTool extends ToolBase {
 
   @Override
   public void runImpl(CommandLine cli) throws Exception {
-
     String zkHost = CLIUtils.getZkHost(cli);
     String path = cli.getArgs()[0];
 
@@ -67,8 +94,30 @@ public class UpdateACLTool extends ToolBase {
             .withUrl(zkHost)
             .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
             .build()) {
+      doUpdateAcls(zkClient, path);
+    }
+  }
+
+  private void doUpdateAcls(SolrZkClient zkClient, String path) throws Exception {
+    zkClient.updateACLs(path);
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
 
-      zkClient.updateACLs(path);
+    if (!ZkController.checkChrootPath(zkHost, true)) {
+      throw new IllegalStateException(
+          "A chroot was specified in zkHost but the znode doesn't exist.");
+    }
+
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doUpdateAcls(zkClient, path);
+      return 0;
     }
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/VersionTool.java b/solr/core/src/java/org/apache/solr/cli/VersionTool.java
index 8bb14172467..a7b66e89a8a 100644
--- a/solr/core/src/java/org/apache/solr/cli/VersionTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/VersionTool.java
@@ -30,8 +30,19 @@ import org.apache.solr.client.solrj.response.SystemInfoResponse;
  * --solr-connection}, {@code --solr-url}, or {@code --zk-host}) is provided, also prints the
  * version of the remote Solr server.
  */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(name = "version", description = "Prints the Solr version.")
 public class VersionTool extends ToolBase {
 
+  @picocli.CommandLine.ArgGroup(exclusive = true, multiplicity = "0..1")
+  private ConnectionOptions connectionOptions;
+
+  @picocli.CommandLine.Mixin private CredentialsOptions credentialsOptions;
+
+  public VersionTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public VersionTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -55,10 +66,27 @@ public class VersionTool extends ToolBase {
     if (CLIUtils.hasConnectionOption(cli)) {
       String solrUrl = CLIUtils.normalizeSolrUrl(cli);
       String credentials = cli.getOptionValue(CommonCLIOptions.CREDENTIALS_OPTION);
-      try (var solrClient = CLIUtils.getSolrClient(solrUrl, credentials)) {
-        SystemInfoResponse sysResponse = new SystemInfoRequest().process(solrClient);
-        echo("Server version: " + sysResponse.getSolrImplVersion());
-      }
+      echoServerVersion(solrUrl, credentials);
+    }
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    echo("Client version: " + SolrVersion.LATEST);
+
+    String solrUrl = connectionOptions == null ? null : connectionOptions.effectiveSolrUrl();
+    if (solrUrl != null) {
+      echoServerVersion(
+          CLIUtils.normalizeSolrUrl(solrUrl),
+          credentialsOptions == null ? null : credentialsOptions.credentials);
+    }
+    return 0;
+  }
+
+  private void echoServerVersion(String solrUrl, String credentials) throws Exception {
+    try (var solrClient = CLIUtils.getSolrClient(solrUrl, credentials)) {
+      SystemInfoResponse sysResponse = new SystemInfoRequest().process(solrClient);
+      echo("Server version: " + sysResponse.getSolrImplVersion());
     }
   }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkConnectionOptions.java b/solr/core/src/java/org/apache/solr/cli/ZkConnectionOptions.java
new file mode 100644
index 00000000000..c83a58ade0b
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cli/ZkConnectionOptions.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.util.Map;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.impl.CloudSolrClient;
+import picocli.CommandLine;
+
+/**
+ * Picocli mixin providing common ZooKeeper connection options shared across ZK sub-commands.
+ *
+ * <p>Use {@code @CommandLine.Mixin ZkConnectionOptions zkOpts} in a command class to inherit these
+ * options. Call {@link #resolveZkHost()} to obtain a resolved ZooKeeper connection string, applying
+ * the same fallback logic as the commons-cli path.
+ */
+public class ZkConnectionOptions {
+
+  @CommandLine.Option(
+      names = {"-z", "--zk-host"},
+      description =
+          "Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to "
+              + CommonCLIOptions.DefaultValues.ZK_HOST
+              + '.')
+  public String zkHost;
+
+  @CommandLine.Option(
+      names = {"-s", "--solr-connection"},
+      description =
+          "Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to "
+              + CommonCLIOptions.DefaultValues.ZK_HOST
+              + '.')
+  public String solrConnection;
+
+  @CommandLine.Option(
+      names = {"--solr-url"},
+      description =
+          "Base Solr URL, which can be used to determine the zk-host if --zk-host is not known")
+  public String solrUrl;
+
+  @CommandLine.Option(
+      names = {"-u", "--credentials"},
+      description =
+          "Credentials in the format username:password. Example: --credentials solr:SolrRocks")
+  public String credentials;
+
+  /**
+   * Resolves the ZooKeeper connection string using the following precedence:
+   *
+   * <ol>
+   *   <li>{@code --solr-connection} option value; used directly if it is a ZooKeeper connection
+   *       string, otherwise treated as a Solr URL to query for the ZooKeeper host
+   *   <li>Explicit {@code --zk-host} option value
+   *   <li>ZooKeeper host derived by querying the Solr instance at {@code --solr-url}
+   *   <li>ZooKeeper host derived by querying the default Solr URL ({@code http://localhost:8983}),
+   *       with a warning printed to stderr
+   * </ol>
+   *
+   * @return resolved ZooKeeper connection string, never null
+   * @throws IllegalStateException if the Solr instance is not running in SolrCloud mode
+   * @throws Exception if the Solr instance cannot be reached
+   */
+  public String resolveZkHost() throws Exception {
+    if (solrConnection != null) {
+      var connection = CloudSolrClient.CloudSolrClientConnection.parse(solrConnection);
+      if (connection.isZookeeper()) {
+        return solrConnection;
+      }
+      // HTTP form: the connection string names a Solr URL, so ask that Solr for its ZooKeeper
+      return zkHostFromSolrUrl(CLIUtils.normalizeSolrUrl(connection.quorumItems().get(0)));
+    }
+
+    if (zkHost != null) {
+      return zkHost;
+    }
+
+    String resolvedSolrUrl = solrUrl;
+    if (resolvedSolrUrl == null) {
+      resolvedSolrUrl = CLIUtils.getDefaultSolrUrl();
+      CLIO.err(
+          "Neither --solr-connection, --zk-host or --solr-url parameters, nor SOLR_CONNECTION, ZK_HOST env var provided, so assuming solr url is "
+              + resolvedSolrUrl
+              + ".");
+    }
+    return zkHostFromSolrUrl(resolvedSolrUrl);
+  }
+
+  private String zkHostFromSolrUrl(String resolvedSolrUrl) throws Exception {
+    try (SolrClient solrClient = CLIUtils.getSolrClient(resolvedSolrUrl, credentials)) {
+      Map<String, Object> status = StatusTool.reportStatus(solrClient);
+      @SuppressWarnings("unchecked")
+      Map<String, Object> cloud = (Map<String, Object>) status.get("cloud");
+      if (cloud != null) {
+        String zookeeper = (String) cloud.get("ZooKeeper");
+        if (zookeeper != null && zookeeper.endsWith("(embedded)")) {
+          zookeeper = zookeeper.substring(0, zookeeper.length() - "(embedded)".length());
+        }
+        if (zookeeper != null) {
+          return zookeeper;
+        }
+      }
+    }
+
+    throw new IllegalStateException(
+        "Solr at "
+            + resolvedSolrUrl
+            + " is not running in SolrCloud mode. Cannot use zk commands.");
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkCpTool.java b/solr/core/src/java/org/apache/solr/cli/ZkCpTool.java
index 2edaccee9df..7d8959f8eec 100644
--- a/solr/core/src/java/org/apache/solr/cli/ZkCpTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ZkCpTool.java
@@ -40,6 +40,29 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Supports zk cp command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "cp",
+    description = {
+      "Copy files or folders to/from ZooKeeper or ZooKeeper to ZooKeeper.",
+      "",
+      "<src>, <dest> : [file:][/]path/to/local/file or zk:/path/to/zk/node",
+      "NOTE: <src> and <dest> may both be ZooKeeper resources prefixed by 'zk:'",
+      "When <src> is a zk resource, <dest> may be '.'",
+      "If <dest> ends with '/', then <dest> will be a local folder or parent znode",
+      "and the last element of the <src> path will be appended unless <src> also ends in a slash."
+    },
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Upload a local file to ZooKeeper",
+      "  bin/solr zk cp /local/conf/solrconfig.xml zk:/configs/myconfig/solrconfig.xml -z localhost:9983",
+      "",
+      "  # Recursively copy a local directory to ZooKeeper",
+      "  bin/solr zk cp -r file:/local/conf/ zk:/configs/myconfig/ -z localhost:9983",
+      "",
+      "  # Copy a ZooKeeper node to the current directory",
+      "  bin/solr zk cp zk:/configs/myconfig/solrconfig.xml . -z localhost:9983"
+    })
 public class ZkCpTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
@@ -51,6 +74,32 @@ public class ZkCpTool extends ToolBase {
           .desc("Required to look up configuration for compressing state.json.")
           .get();
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      description = "Source path: [file:][/]path/to/local/file or zk:/path/to/zk/node.")
+  private String src;
+
+  @picocli.CommandLine.Parameters(
+      index = "1",
+      arity = "1",
+      description = "Destination path: [file:][/]path/to/local/file or zk:/path/to/zk/node.")
+  private String dst;
+
+  @picocli.CommandLine.Mixin RecursiveOption recursiveOpt;
+
+  @picocli.CommandLine.Option(
+      names = {"--solr-home"},
+      description = "Required to look up configuration for compressing state.json.",
+      paramLabel = "DIR")
+  private String solrHome;
+
+  public ZkCpTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ZkCpTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -120,11 +169,16 @@ public class ZkCpTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     String zkHost = CLIUtils.getZkHost(cli);
-
-    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
     String src = cli.getArgs()[0];
     String dst = cli.getArgs()[1];
     boolean recursive = cli.hasOption(CommonCLIOptions.RECURSIVE_OPTION);
+    String solrHome = cli.getOptionValue(SOLR_HOME_OPTION);
+    doCp(zkHost, src, dst, recursive, solrHome);
+  }
+
+  private void doCp(String zkHost, String src, String dst, boolean recursive, String solrHome)
+      throws Exception {
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
     echo("Copying from '" + src + "' to '" + dst + "'. ZooKeeper at " + zkHost);
 
     boolean srcIsZk = src.toLowerCase(Locale.ROOT).startsWith("zk:");
@@ -153,7 +207,6 @@ public class ZkCpTool extends ToolBase {
     Compressor compressor = new ZLibCompressor();
 
     if (dstIsZk) {
-      String solrHome = cli.getOptionValue(SOLR_HOME_OPTION);
       if (StrUtils.isNullOrEmpty(solrHome)) {
         solrHome = EnvUtils.getProperty("solr.home");
       }
@@ -208,4 +261,10 @@ public class ZkCpTool extends ToolBase {
       throw (e);
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    doCp(zkOpts.resolveZkHost(), src, dst, recursiveOpt.recursive, solrHome);
+    return 0;
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkLsTool.java b/solr/core/src/java/org/apache/solr/cli/ZkLsTool.java
index 2ca4be286f2..ae87c1173d9 100644
--- a/solr/core/src/java/org/apache/solr/cli/ZkLsTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ZkLsTool.java
@@ -17,16 +17,43 @@
 package org.apache.solr.cli;
 
 import java.lang.invoke.MethodHandles;
-import org.apache.commons.cli.CommandLine;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.Options;
+import org.apache.solr.client.solrj.impl.SolrZkClientTimeout;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import picocli.CommandLine;
 
 /** Supports zk ls command in the bin/solr script. */
+@CommandLine.Command(
+    name = "ls",
+    description = "List the contents of a ZooKeeper node.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # List top-level ZooKeeper nodes",
+      "  bin/solr zk ls /configs -z localhost:9983",
+      "",
+      "  # Recursively list a configset",
+      "  bin/solr zk ls -r /configs/myconfig -z localhost:9983"
+    })
 public class ZkLsTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
+  @CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      description = "The path of the ZooKeeper znode path to list.")
+  private String path;
+
+  @CommandLine.Mixin RecursiveOption recursiveOpt;
+
+  public ZkLsTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ZkLsTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -51,22 +78,43 @@ public class ZkLsTool extends ToolBase {
   }
 
   @Override
-  public void runImpl(CommandLine cli) throws Exception {
+  public void runImpl(org.apache.commons.cli.CommandLine cli) throws Exception {
     String zkHost = CLIUtils.getZkHost(cli);
     String znode = cli.getArgs()[0];
+    boolean recursive = cli.hasOption(CommonCLIOptions.RECURSIVE_OPTION);
 
     try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
-      echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+      doLs(zkClient, zkHost, znode, recursive);
+    } catch (Exception e) {
+      log.error("Could not complete ls operation for reason: ", e);
+      throw (e);
+    }
+  }
+
+  private void doLs(SolrZkClient zkClient, String zkHost, String znode, boolean recursive)
+      throws Exception {
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+    echoIfVerbose(
+        "Getting listing for ZooKeeper node "
+            + znode
+            + " from ZooKeeper at "
+            + zkHost
+            + " recursive: "
+            + recursive);
+    runtime.print(zkClient.listZnode(znode, recursive));
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
 
-      boolean recursive = cli.hasOption(CommonCLIOptions.RECURSIVE_OPTION);
-      echoIfVerbose(
-          "Getting listing for ZooKeeper node "
-              + znode
-              + " from ZooKeeper at "
-              + zkHost
-              + " recursive: "
-              + recursive);
-      runtime.print(zkClient.listZnode(znode, recursive));
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doLs(zkClient, zkHost, path, recursiveOpt.recursive);
+      return 0;
     } catch (Exception e) {
       log.error("Could not complete ls operation for reason: ", e);
       throw (e);
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkMkrootTool.java b/solr/core/src/java/org/apache/solr/cli/ZkMkrootTool.java
index 2a644f2a136..ee0225694fe 100644
--- a/solr/core/src/java/org/apache/solr/cli/ZkMkrootTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ZkMkrootTool.java
@@ -19,14 +19,30 @@ package org.apache.solr.cli;
 import static org.apache.solr.packagemanager.PackageUtils.format;
 
 import java.lang.invoke.MethodHandles;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Option;
 import org.apache.commons.cli.Options;
+import org.apache.solr.client.solrj.impl.SolrZkClientTimeout;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Supports zk mkroot command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "mkroot",
+    description =
+        "Make a znode in ZooKeeper with no data. Can be used to make a path of arbitrary depth"
+            + " but primarily intended to create a 'chroot'.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Create a chroot path for Solr",
+      "  bin/solr zk mkroot /solr -z localhost:2181",
+      "",
+      "  # Create a nested chroot path",
+      "  bin/solr zk mkroot /solr/myapp -z localhost:2181"
+    })
 public class ZkMkrootTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
@@ -37,6 +53,37 @@ public class ZkMkrootTool extends ToolBase {
           .desc("Raise an error if the root exists.  Defaults to false.")
           .get();
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      description = "The ZooKeeper znode path to create.")
+  private String path;
+
+  /**
+   * Declared as a String rather than a boolean so that supplying a value keeps the commons-cli
+   * meaning: that parser uses hasOption(), so the option counts as set whatever value follows it.
+   * Binding a boolean here would make '--fail-on-exists false' mean false under picocli and true
+   * under commons-cli. Making both parsers honour the value is a behaviour change for existing
+   * users and belongs in its own change.
+   */
+  @picocli.CommandLine.Option(
+      names = {"--fail-on-exists"},
+      arity = "0..1",
+      fallbackValue = "true",
+      paramLabel = "<arg>",
+      description = "Raise an error if the znode already exists. Defaults to false.")
+  private String failOnExistsValue;
+
+  private boolean failOnExists() {
+    return failOnExistsValue != null;
+  }
+
+  public ZkMkrootTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ZkMkrootTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -76,10 +123,31 @@ public class ZkMkrootTool extends ToolBase {
     boolean failOnExists = cli.hasOption(FAIL_ON_EXISTS_OPTION);
 
     try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
-      echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+      doMkroot(zkClient, zkHost, znode, failOnExists);
+    } catch (Exception e) {
+      log.error("Could not complete mkroot operation for reason: ", e);
+      throw (e);
+    }
+  }
+
+  private void doMkroot(SolrZkClient zkClient, String zkHost, String znode, boolean failOnExists)
+      throws Exception {
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+    echo("Creating ZooKeeper path " + znode + " on ZooKeeper at " + zkHost);
+    zkClient.makePath(znode, failOnExists);
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
 
-      echo("Creating ZooKeeper path " + znode + " on ZooKeeper at " + zkHost);
-      zkClient.makePath(znode, failOnExists);
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doMkroot(zkClient, zkHost, path, failOnExists());
+      return 0;
     } catch (Exception e) {
       log.error("Could not complete mkroot operation for reason: ", e);
       throw (e);
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkMvTool.java b/solr/core/src/java/org/apache/solr/cli/ZkMvTool.java
index a11ac0884f8..8a0db12b037 100644
--- a/solr/core/src/java/org/apache/solr/cli/ZkMvTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ZkMvTool.java
@@ -20,17 +20,55 @@ import static org.apache.solr.packagemanager.PackageUtils.format;
 
 import java.lang.invoke.MethodHandles;
 import java.util.Locale;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
 import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.SolrZkClientTimeout;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Supports zk mv command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "mv",
+    description = {
+      "Move (rename) a znode on ZooKeeper.",
+      "",
+      "<src>, <dest> : ZooKeeper nodes, the 'zk:' prefix is optional.",
+      "If <dest> ends with '/', then <dest> will be a parent znode",
+      "and the last element of the <src> path will be appended."
+    },
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Rename a configset",
+      "  bin/solr zk mv /configs/oldconfig /configs/myconfig -z localhost:9983",
+      "",
+      "  # Move a node into a parent znode",
+      "  bin/solr zk mv /configs/myconfig /configs/ -z localhost:9983"
+    })
 public class ZkMvTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      description = "Source ZooKeeper znode path (zk: prefix optional).")
+  private String src;
+
+  @picocli.CommandLine.Parameters(
+      index = "1",
+      arity = "1",
+      description = "Destination ZooKeeper znode path (zk: prefix optional).")
+  private String dst;
+
+  public ZkMvTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ZkMvTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -70,29 +108,49 @@ public class ZkMvTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     String zkHost = CLIUtils.getZkHost(cli);
+    String src = cli.getArgs()[0];
+    String dst = cli.getArgs()[1];
 
     try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
-      echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
-      String src = cli.getArgs()[0];
-      String dst = cli.getArgs()[1];
-
-      if (src.toLowerCase(Locale.ROOT).startsWith("file:")
-          || dst.toLowerCase(Locale.ROOT).startsWith("file:")) {
-        throw new SolrServerException(
-            "mv command operates on znodes and 'file:' has been specified.");
-      }
-      String source = src;
-      if (src.toLowerCase(Locale.ROOT).startsWith("zk")) {
-        source = src.substring(3);
-      }
-
-      String dest = dst;
-      if (dst.toLowerCase(Locale.ROOT).startsWith("zk")) {
-        dest = dst.substring(3);
-      }
-
-      echo("Moving Znode " + source + " to " + dest + " on ZooKeeper at " + zkHost);
-      zkClient.moveZnode(source, dest);
+      doMv(zkClient, zkHost, src, dst);
+    } catch (Exception e) {
+      log.error("Could not complete mv operation for reason: ", e);
+      throw (e);
+    }
+  }
+
+  private void doMv(SolrZkClient zkClient, String zkHost, String src, String dst) throws Exception {
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+    if (src.toLowerCase(Locale.ROOT).startsWith("file:")
+        || dst.toLowerCase(Locale.ROOT).startsWith("file:")) {
+      throw new SolrServerException(
+          "mv command operates on znodes and 'file:' has been specified.");
+    }
+    String source = src;
+    if (src.toLowerCase(Locale.ROOT).startsWith("zk:")) {
+      source = src.substring(3);
+    }
+
+    String dest = dst;
+    if (dst.toLowerCase(Locale.ROOT).startsWith("zk:")) {
+      dest = dst.substring(3);
+    }
+
+    echo("Moving Znode " + source + " to " + dest + " on ZooKeeper at " + zkHost);
+    zkClient.moveZnode(source, dest);
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
+
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doMv(zkClient, zkHost, src, dst);
+      return 0;
     } catch (Exception e) {
       log.error("Could not complete mv operation for reason: ", e);
       throw (e);
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkRmTool.java b/solr/core/src/java/org/apache/solr/cli/ZkRmTool.java
index 92e6b4daaa4..c657d456bc5 100644
--- a/solr/core/src/java/org/apache/solr/cli/ZkRmTool.java
+++ b/solr/core/src/java/org/apache/solr/cli/ZkRmTool.java
@@ -18,17 +18,45 @@ package org.apache.solr.cli;
 
 import java.lang.invoke.MethodHandles;
 import java.util.Locale;
+import java.util.concurrent.TimeUnit;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
 import org.apache.solr.client.solrj.SolrServerException;
+import org.apache.solr.client.solrj.impl.SolrZkClientTimeout;
 import org.apache.solr.common.cloud.SolrZkClient;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Supports zk rm command in the bin/solr script. */
+@SuppressWarnings("UnnecessarilyFullyQualified")
+@picocli.CommandLine.Command(
+    name = "rm",
+    description = "Remove a znode from ZooKeeper.",
+    footerHeading = "%nExamples:%n",
+    footer = {
+      "  # Remove a single file from ZooKeeper",
+      "  bin/solr zk rm /configs/myconfig/solrconfig.xml -z localhost:9983",
+      "",
+      "  # Recursively remove a configset",
+      "  bin/solr zk rm -r /configs/myconfig -z localhost:9983"
+    })
 public class ZkRmTool extends ToolBase {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
+  @picocli.CommandLine.Mixin ZkConnectionOptions zkOpts;
+
+  @picocli.CommandLine.Parameters(
+      index = "0",
+      arity = "1",
+      description = "The ZooKeeper znode path to remove (zk: prefix optional).")
+  private String path;
+
+  @picocli.CommandLine.Mixin RecursiveOption recursiveOpt;
+
+  public ZkRmTool() {
+    this(new DefaultToolRuntime());
+  }
+
   public ZkRmTool(ToolRuntime runtime) {
     super(runtime);
   }
@@ -54,10 +82,25 @@ public class ZkRmTool extends ToolBase {
   @Override
   public void runImpl(CommandLine cli) throws Exception {
     String zkHost = CLIUtils.getZkHost(cli);
-
     String target = cli.getArgs()[0];
     boolean recursive = cli.hasOption(CommonCLIOptions.RECURSIVE_OPTION);
 
+    String znode = resolveZnode(target);
+
+    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
+    try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
+      doRm(zkClient, zkHost, znode, recursive);
+    } catch (Exception e) {
+      log.error("Could not complete rm operation for reason: ", e);
+      throw (e);
+    }
+  }
+
+  /**
+   * Strips an optional {@code zk:} prefix and rejects the root node. Called before connecting, so
+   * that removing '/' is reported immediately rather than requiring a reachable ZooKeeper.
+   */
+  private static String resolveZnode(String target) throws SolrServerException {
     String znode = target;
     if (target.toLowerCase(Locale.ROOT).startsWith("zk:")) {
       znode = target.substring(3);
@@ -65,20 +108,37 @@ public class ZkRmTool extends ToolBase {
     if (znode.equals("/")) {
       throw new SolrServerException("You may not remove the root ZK node ('/')!");
     }
-    echoIfVerbose("\nConnecting to ZooKeeper at " + zkHost + " ...");
-    try (SolrZkClient zkClient = CLIUtils.getSolrZkClient(cli, zkHost)) {
-      if (!recursive && !zkClient.getChildren(znode, null).isEmpty()) {
-        throw new SolrServerException(
-            "ZooKeeper node " + znode + " has children and recursive has NOT been specified.");
-      }
-      echo(
-          "Removing ZooKeeper node "
-              + znode
-              + " from ZooKeeper at "
-              + zkHost
-              + " recursive: "
-              + recursive);
-      zkClient.clean(znode);
+    return znode;
+  }
+
+  private void doRm(SolrZkClient zkClient, String zkHost, String znode, boolean recursive)
+      throws Exception {
+    if (!recursive && !zkClient.getChildren(znode, null).isEmpty()) {
+      throw new SolrServerException(
+          "ZooKeeper node " + znode + " has children and recursive has NOT been specified.");
+    }
+    echo(
+        "Removing ZooKeeper node "
+            + znode
+            + " from ZooKeeper at "
+            + zkHost
+            + " recursive: "
+            + recursive);
+    zkClient.clean(znode);
+  }
+
+  @Override
+  public int callTool() throws Exception {
+    String zkHost = zkOpts.resolveZkHost();
+    String znode = resolveZnode(path);
+
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(zkHost)
+            .withTimeout(SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT, TimeUnit.MILLISECONDS)
+            .build()) {
+      doRm(zkClient, zkHost, znode, recursiveOpt.recursive);
+      return 0;
     } catch (Exception e) {
       log.error("Could not complete rm operation for reason: ", e);
       throw (e);
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkTool.java b/solr/core/src/java/org/apache/solr/cli/ZkTool.java
new file mode 100644
index 00000000000..bed1805853c
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/cli/ZkTool.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.util.concurrent.Callable;
+import picocli.CommandLine;
+
+/**
+ * Sub commands for working with ZooKeeper, only here to provide a common parent for the subcommands
+ * and print tool help.
+ */
+@CommandLine.Command(
+    name = "zk",
+    description = "Sub commands for working with ZooKeeper.",
+    footer = "\nPass --help or -h after any COMMAND to see command-specific usage information.",
+    subcommands = {
+      ConfigSetDownloadTool.class,
+      ConfigSetUploadTool.class,
+      ZkCpTool.class,
+      ZkLsTool.class,
+      ZkMkrootTool.class,
+      ZkMvTool.class,
+      ZkRmTool.class,
+      UpdateACLTool.class
+    })
+public class ZkTool implements Callable<Integer> {
+
+  @CommandLine.Mixin HelpMixin helpMixin;
+
+  @CommandLine.Spec CommandLine.Model.CommandSpec spec;
+
+  @Override
+  public Integer call() {
+    spec.commandLine().usage(CLIO.getOutStream());
+    return 0;
+  }
+}
diff --git a/solr/core/src/java/org/apache/solr/cli/ZkToolHelp.java b/solr/core/src/java/org/apache/solr/cli/ZkToolHelp.java
index 9251484a859..8217702f511 100644
--- a/solr/core/src/java/org/apache/solr/cli/ZkToolHelp.java
+++ b/solr/core/src/java/org/apache/solr/cli/ZkToolHelp.java
@@ -77,4 +77,9 @@ public class ZkToolHelp extends ToolBase {
           "Pass --help or -h after any COMMAND to see command-specific usage information such as: ./solr zk ls --help");
     }
   }
+
+  @Override
+  public int callTool() throws Exception {
+    throw new UnsupportedOperationException("This tool does not yet support PicoCli");
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/AuthToolPicocliTest.java b/solr/core/src/test/org/apache/solr/cli/AuthToolPicocliTest.java
new file mode 100644
index 00000000000..41a4385b55d
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cli/AuthToolPicocliTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.solr.cli;
+
+import java.util.Arrays;
+import picocli.CommandLine;
+
+/**
+ * Runs all {@link AuthToolTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited from {@link AuthToolTest}. Only the tool invocation
+ * strategy is overridden here to use {@code picocli.CommandLine.execute()} instead of the
+ * commons-cli path.
+ */
+public class AuthToolPicocliTest extends AuthToolTest {
+
+  @Override
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    // args[0] is the tool name used by commons-cli dispatch; strip it for picocli.
+    String[] toolArgs = Arrays.copyOfRange(args, 1, args.length);
+    // Use a TestingRuntime so runtime.exit() cannot terminate the test JVM
+    ToolRuntime runtime = new CLITestHelper.TestingRuntime(false);
+    ToolBase tool = clazz.getDeclaredConstructor(ToolRuntime.class).newInstance(runtime);
+    return new CommandLine(tool)
+        .setDefaultValueProvider(new CliDefaultValueProvider())
+        .execute(toolArgs);
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java b/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java
index aa31392a2f4..b0e94f2ef8d 100644
--- a/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/AuthToolTest.java
@@ -60,6 +60,10 @@ public class AuthToolTest extends SolrCloudTestCase {
     }
   }
 
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    return CLITestHelper.runTool(args, clazz);
+  }
+
   @Test
   public void testEnableAuth() throws Exception {
     Path solrIncludeFile = Files.createFile(dir.resolve("solrIncludeFile.txt"));
@@ -77,7 +81,7 @@ public class AuthToolTest extends SolrCloudTestCase {
       "--block-unknown",
       "true"
     };
-    assertEquals(0, CLITestHelper.runTool(args, AuthTool.class));
+    assertEquals(0, runTool(args, AuthTool.class));
   }
 
   @Test
@@ -95,7 +99,7 @@ public class AuthToolTest extends SolrCloudTestCase {
       "--credentials",
       "solr:solr"
     };
-    assertNotEquals(0, CLITestHelper.runTool(args, AuthTool.class));
+    assertNotEquals(0, runTool(args, AuthTool.class));
   }
 
   @Test
@@ -115,7 +119,7 @@ public class AuthToolTest extends SolrCloudTestCase {
         "--credentials",
         "solr:solr"
       };
-      assertEquals(0, CLITestHelper.runTool(args, AuthTool.class));
+      assertEquals(0, runTool(args, AuthTool.class));
     } finally {
       System.clearProperty(Sha256AuthenticationProvider.ALLOW_USER_AS_PASSWORD_PROP);
     }
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/test/org/apache/solr/cli/CreateToolPicocliTest.java
similarity index 50%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/test/org/apache/solr/cli/CreateToolPicocliTest.java
index d045ae35501..7eddb2c923b 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/test/org/apache/solr/cli/CreateToolPicocliTest.java
@@ -16,24 +16,24 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import java.util.Arrays;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
+/**
+ * Runs all {@link CreateToolTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited; only the invocation strategy is overridden.
+ */
+public class CreateToolPicocliTest extends CreateToolTest {
 
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
+  @Override
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    // args[0] is the tool name used by commons-cli dispatch; strip it for picocli.
+    String[] toolArgs = Arrays.copyOfRange(args, 1, args.length);
+    ToolRuntime runtime = new CLITestHelper.TestingRuntime(false);
+    ToolBase tool = clazz.getDeclaredConstructor(ToolRuntime.class).newInstance(runtime);
+    return new CommandLine(tool)
+        .setDefaultValueProvider(new CliDefaultValueProvider())
+        .execute(toolArgs);
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/CreateToolTest.java b/solr/core/src/test/org/apache/solr/cli/CreateToolTest.java
index 74c9a641166..e1df239b51b 100644
--- a/solr/core/src/test/org/apache/solr/cli/CreateToolTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/CreateToolTest.java
@@ -24,6 +24,11 @@ import org.junit.Test;
 
 public class CreateToolTest extends SolrCloudTestCase {
 
+  /** Runs the tool. Overridden by the picocli variant of this test. */
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    return CLITestHelper.runTool(args, clazz);
+  }
+
   private static final String collectionName = "testCreateCollectionWithBasicAuth";
 
   @BeforeClass
@@ -50,6 +55,6 @@ public class CreateToolTest extends SolrCloudTestCase {
       "--verbose"
     };
 
-    assertEquals(0, CLITestHelper.runTool(args, CreateTool.class));
+    assertEquals(0, runTool(args, CreateTool.class));
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/test/org/apache/solr/cli/DeleteToolPicocliTest.java
similarity index 50%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/test/org/apache/solr/cli/DeleteToolPicocliTest.java
index d045ae35501..308e9d87237 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/test/org/apache/solr/cli/DeleteToolPicocliTest.java
@@ -16,24 +16,24 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import java.util.Arrays;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
+/**
+ * Runs all {@link DeleteToolTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited; only the invocation strategy is overridden.
+ */
+public class DeleteToolPicocliTest extends DeleteToolTest {
 
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
+  @Override
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    // args[0] is the tool name used by commons-cli dispatch; strip it for picocli.
+    String[] toolArgs = Arrays.copyOfRange(args, 1, args.length);
+    ToolRuntime runtime = new CLITestHelper.TestingRuntime(false);
+    ToolBase tool = clazz.getDeclaredConstructor(ToolRuntime.class).newInstance(runtime);
+    return new CommandLine(tool)
+        .setDefaultValueProvider(new CliDefaultValueProvider())
+        .execute(toolArgs);
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/DeleteToolTest.java b/solr/core/src/test/org/apache/solr/cli/DeleteToolTest.java
index 3c447d4a201..4e1bebe3884 100644
--- a/solr/core/src/test/org/apache/solr/cli/DeleteToolTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/DeleteToolTest.java
@@ -27,6 +27,11 @@ import org.junit.Test;
 
 public class DeleteToolTest extends SolrCloudTestCase {
 
+  /** Runs the tool. Overridden by the picocli variant of this test. */
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    return CLITestHelper.runTool(args, clazz);
+  }
+
   @BeforeClass
   public static void setupClusterWithSecurityEnabled() throws Exception {
     configureCluster(2)
@@ -62,7 +67,7 @@ public class DeleteToolTest extends SolrCloudTestCase {
       SecurityJson.USER_PASS,
       "--verbose"
     };
-    assertEquals(0, CLITestHelper.runTool(args, DeleteTool.class));
+    assertEquals(0, runTool(args, DeleteTool.class));
   }
 
   @Test
@@ -90,7 +95,7 @@ public class DeleteToolTest extends SolrCloudTestCase {
         SecurityJson.USER_PASS,
         "--verbose"
       };
-      assertEquals(0, CLITestHelper.runTool(args, DeleteTool.class));
+      assertEquals(0, runTool(args, DeleteTool.class));
     }
   }
 
@@ -114,7 +119,7 @@ public class DeleteToolTest extends SolrCloudTestCase {
       cluster.getZkClient().getZkServerAddress(),
       "--verbose"
     };
-    assertEquals(1, CLITestHelper.runTool(args, DeleteTool.class));
+    assertEquals(1, runTool(args, DeleteTool.class));
   }
 
   @Test
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLIPicocliTest.java b/solr/core/src/test/org/apache/solr/cli/SolrCLIPicocliTest.java
new file mode 100644
index 00000000000..7929150b788
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cli/SolrCLIPicocliTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.solr.SolrTestCase;
+import org.junit.Test;
+import picocli.CommandLine;
+
+/**
+ * Guard-rails for the picocli command tree that apply to every command, so that porting a new tool
+ * cannot silently regress them.
+ */
+public class SolrCLIPicocliTest extends SolrTestCase {
+
+  private static CommandLine rootCommand() {
+    return new CommandLine(new SolrCLI());
+  }
+
+  /** Every command in the tree, including the root, as "path" -> CommandLine. */
+  private static List<CommandLine> allCommands(CommandLine root) {
+    List<CommandLine> all = new ArrayList<>();
+    all.add(root);
+    collectSubcommands(root, all);
+    return all;
+  }
+
+  private static void collectSubcommands(CommandLine parent, List<CommandLine> into) {
+    for (CommandLine sub : parent.getSubcommands().values()) {
+      into.add(sub);
+      collectSubcommands(sub, into);
+    }
+  }
+
+  /** The argv a user would type to reach this command, e.g. {@code ["zk", "ls"]}. */
+  private static String[] pathOf(CommandLine cmd) {
+    List<String> path = new ArrayList<>();
+    for (CommandLine c = cmd; c != null; c = c.getParent()) {
+      path.add(0, c.getCommandName());
+    }
+    // Drop the root "solr" element; it is not typed as an argument.
+    path.remove(0);
+    return path.toArray(new String[0]);
+  }
+
+  private static String[] withArg(String[] path, String arg) {
+    String[] args = new String[path.length + 1];
+    System.arraycopy(path, 0, args, 0, path.length);
+    args[path.length] = arg;
+    return args;
+  }
+
+  /**
+   * Running {@code bin/solr} with no command must print usage rather than throwing picocli's
+   * "Parsed command is not a Method, Runnable or Callable".
+   */
+  @Test
+  public void testNoArgsPrintsUsageAndExitsNonZero() {
+    StringWriter out = new StringWriter();
+    CommandLine cmd = rootCommand();
+    cmd.setOut(new PrintWriter(out));
+    cmd.setErr(new PrintWriter(new StringWriter()));
+
+    int exitCode = cmd.execute();
+
+    assertEquals("bare invocation should exit non-zero, as the commons-cli path does", 1, exitCode);
+    assertTrue("bare invocation should print usage, got: " + out, out.toString().contains("solr"));
+  }
+
+  /** {@code bin/solr --help} reaches the tool as a single empty argument via the shell script. */
+  @Test
+  public void testEmptyLeadingArgIsStripped() {
+    assertArrayEquals(new String[0], SolrCLI.stripEmptyLeadingArg(new String[] {""}));
+    assertArrayEquals(
+        new String[] {"status"}, SolrCLI.stripEmptyLeadingArg(new String[] {"", "status"}));
+    assertArrayEquals(
+        new String[] {"status"}, SolrCLI.stripEmptyLeadingArg(new String[] {"status"}));
+    assertArrayEquals(new String[0], SolrCLI.stripEmptyLeadingArg(new String[0]));
+  }
+
+  /**
+   * Every command must answer both spellings of the help option. Without this, porting a tool and
+   * forgetting the help mixin leaves {@code --help} reported as an unknown option.
+   */
+  @Test
+  public void testEveryCommandSupportsHelp() {
+    for (CommandLine cmd : allCommands(rootCommand())) {
+      String[] path = pathOf(cmd);
+      for (String helpOption : new String[] {"-h", "--help"}) {
+        CommandLine root = rootCommand();
+        root.setOut(new PrintWriter(new StringWriter()));
+        root.setErr(new PrintWriter(new StringWriter()));
+        int exitCode = root.execute(withArg(path, helpOption));
+        assertEquals(
+            "'bin/solr " + String.join(" ", withArg(path, helpOption)) + "' should exit 0",
+            0,
+            exitCode);
+      }
+    }
+  }
+
+  /** {@code --version} is only meaningful on the top-level command. */
+  @Test
+  public void testOnlyRootDeclaresVersionOption() {
+    CommandLine root = rootCommand();
+    List<CommandLine> subcommands = new ArrayList<>();
+    collectSubcommands(root, subcommands);
+    for (CommandLine cmd : subcommands) {
+      for (CommandLine.Model.OptionSpec option : cmd.getCommandSpec().options()) {
+        for (String name : option.names()) {
+          assertFalse(
+              "'bin/solr "
+                  + String.join(" ", pathOf(cmd))
+                  + "' must not declare "
+                  + name
+                  + "; --version belongs on the top-level command only",
+              "--version".equals(name) || "-V".equals(name));
+        }
+      }
+    }
+  }
+
+  /** The root command keeps the commons-cli spellings of the version option. */
+  @Test
+  public void testRootVersionOptionSpellings() {
+    List<String> names = new ArrayList<>();
+    for (CommandLine.Model.OptionSpec option : rootCommand().getCommandSpec().options()) {
+      names.addAll(List.of(option.names()));
+    }
+    assertTrue("-v should request the version, as with commons-cli", names.contains("-v"));
+    assertTrue(names.contains("--version"));
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
index d045ae35501..57839924535 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
@@ -18,6 +18,7 @@ package org.apache.solr.cli;
 
 import org.apache.solr.SolrTestCase;
 import org.junit.Test;
+import picocli.CommandLine;
 
 public class SolrCLITest extends SolrTestCase {
 
@@ -36,4 +37,27 @@ public class SolrCLITest extends SolrTestCase {
     assertEquals(
         "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
   }
+
+  @Test
+  public void testFirstLineOnlyHelpFactoryTruncatesMultiLineDescription() {
+    CommandLine cmd = new CommandLine(new MultiLineDescriptionCommand());
+
+    String baseline = cmd.getUsageMessage();
+    assertTrue("baseline should include first line", baseline.contains("First line."));
+    assertTrue("baseline should include second line", baseline.contains("Second line."));
+
+    SolrCLI.installFirstLineOnlyHelpFactory(cmd);
+    String truncated = cmd.getUsageMessage();
+    assertTrue("truncated should include first line", truncated.contains("First line."));
+    assertFalse("truncated should NOT include second line", truncated.contains("Second line."));
+    assertFalse("truncated should NOT include third line", truncated.contains("Third line."));
+  }
+
+  @CommandLine.Command(name = "test")
+  private static class MultiLineDescriptionCommand {
+    @CommandLine.Option(
+        names = "--multi",
+        description = {"First line.", "Second line.", "Third line."})
+    String multi;
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/test/org/apache/solr/cli/StatusToolPicocliTest.java
similarity index 50%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/test/org/apache/solr/cli/StatusToolPicocliTest.java
index d045ae35501..71d9e5ed705 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/test/org/apache/solr/cli/StatusToolPicocliTest.java
@@ -16,24 +16,24 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import java.io.Reader;
+import java.util.Arrays;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
+/**
+ * Runs all {@link StatusToolTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited; only the invocation strategy is overridden.
+ */
+public class StatusToolPicocliTest extends StatusToolTest {
 
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
+  @Override
+  protected Reader runStatusTool(String[] toolArgs) throws Exception {
+    // toolArgs[0] is the tool name used by commons-cli dispatch; strip it for picocli.
+    String[] args = Arrays.copyOfRange(toolArgs, 1, toolArgs.length);
+    CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
+    StatusTool tool = new StatusTool(runtime);
+    new CommandLine(tool).setDefaultValueProvider(new CliDefaultValueProvider()).execute(args);
+    return runtime.getReader();
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/StatusToolTest.java b/solr/core/src/test/org/apache/solr/cli/StatusToolTest.java
index e035db8495c..b01973f8319 100644
--- a/solr/core/src/test/org/apache/solr/cli/StatusToolTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/StatusToolTest.java
@@ -16,6 +16,7 @@
  */
 package org.apache.solr.cli;
 
+import java.io.Reader;
 import java.util.Map;
 import org.apache.solr.cloud.SolrCloudTestCase;
 import org.apache.solr.common.util.Utils;
@@ -30,6 +31,14 @@ public class StatusToolTest extends SolrCloudTestCase {
     configureCluster(2).configure();
   }
 
+  /** Runs the tool and returns a reader over its output. Overridden by the picocli variant. */
+  protected Reader runStatusTool(String[] toolArgs) throws Exception {
+    CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
+    StatusTool tool = new StatusTool(runtime);
+    tool.runTool(SolrCLI.processCommandLineArgs(tool, toolArgs));
+    return runtime.getReader();
+  }
+
   /** Check the tool returns expected details by specifying Solr URL on the command line. */
   @Test
   public void testSolrUrlStatus() throws Exception {
@@ -37,12 +46,8 @@ public class StatusToolTest extends SolrCloudTestCase {
     JettySolrRunner randomJetty = cluster.getRandomJetty(random());
     String baseUrl = randomJetty.getBaseUrl().toString();
 
-    String[] toolArgs = new String[] {"status", "--solr-url", baseUrl};
-    CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
-    StatusTool tool = new StatusTool(runtime);
-    tool.runTool(SolrCLI.processCommandLineArgs(tool, toolArgs));
-
-    Map<?, ?> obj = (Map<?, ?>) Utils.fromJSON(runtime.getReader());
+    Map<?, ?> obj =
+        (Map<?, ?>) Utils.fromJSON(runStatusTool(new String[] {"status", "--solr-url", baseUrl}));
     assertTrue(obj.containsKey("version"));
     assertTrue(obj.containsKey("startTime"));
     assertTrue(obj.containsKey("uptime"));
diff --git a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java b/solr/core/src/test/org/apache/solr/cli/VersionToolPicocliTest.java
similarity index 51%
copy from solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
copy to solr/core/src/test/org/apache/solr/cli/VersionToolPicocliTest.java
index d045ae35501..4c454ecec27 100644
--- a/solr/core/src/test/org/apache/solr/cli/SolrCLITest.java
+++ b/solr/core/src/test/org/apache/solr/cli/VersionToolPicocliTest.java
@@ -16,24 +16,23 @@
  */
 package org.apache.solr.cli;
 
-import org.apache.solr.SolrTestCase;
-import org.junit.Test;
+import java.util.Arrays;
+import picocli.CommandLine;
 
-public class SolrCLITest extends SolrTestCase {
-
-  @Test
-  public void testUptime() {
-    assertEquals("?", SolrCLI.uptime(0));
-    assertEquals("0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(1));
-
-    assertEquals(
-        "Should have rounded down", "0 days, 0 hours, 0 minutes, 0 seconds", SolrCLI.uptime(499));
-    assertEquals(
-        "Should have rounded up", "0 days, 0 hours, 0 minutes, 1 seconds", SolrCLI.uptime(501));
+/**
+ * Runs all {@link VersionToolTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited; only the invocation strategy is overridden.
+ */
+public class VersionToolPicocliTest extends VersionToolTest {
 
-    // Overflow
-    assertEquals("24 days, 20 hours, 31 minutes, 24 seconds", SolrCLI.uptime(Integer.MAX_VALUE));
-    assertEquals(
-        "106751991167 days, 7 hours, 12 minutes, 56 seconds", SolrCLI.uptime(Long.MAX_VALUE));
+  @Override
+  protected String runVersionTool(String[] toolArgs) throws Exception {
+    // toolArgs[0] is the tool name used by commons-cli dispatch; strip it for picocli.
+    String[] args = Arrays.copyOfRange(toolArgs, 1, toolArgs.length);
+    CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
+    VersionTool tool = new VersionTool(runtime);
+    new CommandLine(tool).setDefaultValueProvider(new CliDefaultValueProvider()).execute(args);
+    return runtime.getOutput();
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cli/VersionToolTest.java b/solr/core/src/test/org/apache/solr/cli/VersionToolTest.java
index b8e4ebd4eee..48ffedead43 100644
--- a/solr/core/src/test/org/apache/solr/cli/VersionToolTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/VersionToolTest.java
@@ -28,14 +28,18 @@ public class VersionToolTest extends SolrCloudTestCase {
     configureCluster(1).configure();
   }
 
-  @Test
-  public void testClientVersionOnly() throws Exception {
-    String[] toolArgs = new String[] {"version"};
+  /** Runs the tool and returns everything it printed. Overridden to exercise the picocli path. */
+  protected String runVersionTool(String[] toolArgs) throws Exception {
     CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
     VersionTool tool = new VersionTool(runtime);
     tool.runTool(SolrCLI.processCommandLineArgs(tool, toolArgs));
+    return runtime.getOutput();
+  }
+
+  @Test
+  public void testClientVersionOnly() throws Exception {
+    String output = runVersionTool(new String[] {"version"});
 
-    String output = runtime.getOutput();
     assertTrue("Output should contain 'Client version:'", output.contains("Client version:"));
     assertFalse("Output should not contain 'Server version:'", output.contains("Server version:"));
   }
@@ -45,12 +49,8 @@ public class VersionToolTest extends SolrCloudTestCase {
     JettySolrRunner randomJetty = cluster.getRandomJetty(random());
     String baseUrl = randomJetty.getBaseUrl().toString();
 
-    String[] toolArgs = new String[] {"version", "--solr-url", baseUrl};
-    CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
-    VersionTool tool = new VersionTool(runtime);
-    tool.runTool(SolrCLI.processCommandLineArgs(tool, toolArgs));
+    String output = runVersionTool(new String[] {"version", "--solr-url", baseUrl});
 
-    String output = runtime.getOutput();
     assertTrue("Output should contain 'Client version:'", output.contains("Client version:"));
     assertTrue("Output should contain 'Server version:'", output.contains("Server version:"));
   }
diff --git a/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsPicocliTest.java b/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsPicocliTest.java
new file mode 100644
index 00000000000..9b631013cd5
--- /dev/null
+++ b/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsPicocliTest.java
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.solr.cli;
+
+import java.util.Arrays;
+import picocli.CommandLine;
+
+/**
+ * Runs all {@link ZkSubcommandsTest} tests through the picocli invocation path.
+ *
+ * <p>All {@code @Test} methods are inherited from {@link ZkSubcommandsTest}. Only the tool
+ * invocation strategy is overridden here to use {@code picocli.CommandLine.execute()} instead of
+ * the commons-cli path.
+ */
+public class ZkSubcommandsPicocliTest extends ZkSubcommandsTest {
+
+  @Override
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    // args[0] is the tool/subcommand name used by commons-cli dispatch; strip it for picocli.
+    String[] toolArgs = Arrays.copyOfRange(args, 1, args.length);
+    ToolBase tool = clazz.getDeclaredConstructor().newInstance();
+    return new CommandLine(tool)
+        .setDefaultValueProvider(new CliDefaultValueProvider())
+        .execute(toolArgs);
+  }
+
+  @Override
+  protected int runTool(
+      String[] args, CLITestHelper.TestingRuntime runtime, Class<? extends ToolBase> clazz)
+      throws Exception {
+    String[] toolArgs = Arrays.copyOfRange(args, 1, args.length);
+    ToolBase tool = clazz.getDeclaredConstructor(ToolRuntime.class).newInstance(runtime);
+    return new CommandLine(tool)
+        .setDefaultValueProvider(new CliDefaultValueProvider())
+        .execute(toolArgs);
+  }
+}
diff --git a/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsTest.java b/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsTest.java
index d5aa1a72d5a..4aebf94ee76 100644
--- a/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsTest.java
+++ b/solr/core/src/test/org/apache/solr/cli/ZkSubcommandsTest.java
@@ -46,6 +46,14 @@ import org.junit.Test;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/**
+ * Tests ZK subcommands by invoking tool instances directly (not through the bin/solr script).
+ *
+ * <p>Subclasses override {@link #runTool} to exercise different invocation paths (commons-cli vs
+ * picocli). All {@code @Test} methods are inherited and run in each subclass.
+ *
+ * @see ZkSubcommandsPicocliTest
+ */
 public class ZkSubcommandsTest extends SolrTestCaseJ4 {
   private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
 
@@ -89,6 +97,29 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     }
   }
 
+  /**
+   * Runs a tool via the invocation path under test. {@code args[0]} is the tool name.
+   *
+   * <p>The default implementation uses commons-cli via {@link CLITestHelper#runTool}. Subclasses
+   * override this to exercise a different invocation path (e.g. picocli).
+   */
+  protected int runTool(String[] args, Class<? extends ToolBase> clazz) throws Exception {
+    return CLITestHelper.runTool(args, clazz);
+  }
+
+  /**
+   * Runs a tool with output capture via the invocation path under test. {@code args[0]} is the tool
+   * name.
+   *
+   * <p>The default implementation uses commons-cli via {@link CLITestHelper#runTool}. Subclasses
+   * override this to exercise a different invocation path (e.g. picocli).
+   */
+  protected int runTool(
+      String[] args, CLITestHelper.TestingRuntime runtime, Class<? extends ToolBase> clazz)
+      throws Exception {
+    return CLITestHelper.runTool(args, runtime, clazz);
+  }
+
   @Test
   public void testPut() throws Exception {
     // test put
@@ -101,7 +132,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     String[] args =
         new String[] {"cp", "-z", zkServer.getZkAddress(), localFile.toString(), "zk:/data.txt"};
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     assertArrayEquals(
         zkClient.getData("/data.txt", null, null), data.getBytes(StandardCharsets.UTF_8));
@@ -114,7 +145,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     writer.write(data);
     writer.close();
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     assertArrayEquals(
         zkClient.getData("/data.txt", null, null), data.getBytes(StandardCharsets.UTF_8));
@@ -139,7 +170,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     String[] args =
         new String[] {"cp", "-z", zkServer.getZkAddress(), localFile.toString(), "zk:/state.json"};
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     assertArrayEquals(dataBytes, zkClient.getCuratorFramework().getData().forPath("/state.json"));
     assertArrayEquals(
@@ -160,7 +191,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
 
     args =
         new String[] {"cp", "-z", zkServer.getZkAddress(), localFile.toString(), "zk:/state.json"};
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     byte[] fromZkRaw =
         zkClient.getCuratorFramework().getData().undecompressed().forPath("/state.json");
@@ -193,7 +224,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
           "zk:/foo.xml"
         };
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     String fromZk = new String(zkClient.getData("/foo.xml", null, null), StandardCharsets.UTF_8);
     Path localFile = SOLR_HOME.resolve("solr-stress-new.xml");
@@ -213,7 +244,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
           "zk:foo.xml"
         };
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     String fromZk = new String(zkClient.getData("/foo.xml", null, null), StandardCharsets.UTF_8);
     Path localFile = SOLR_HOME.resolve("solr-stress-new.xml");
@@ -235,7 +266,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
           "zk:/state.json"
         };
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     Path locFile = SOLR_HOME.resolve("solr-stress-new.xml");
     byte[] fileBytes = Files.readAllBytes(locFile);
@@ -252,7 +283,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
         "Should get back an uncompressed version what we put in ZK", fileBytes, fromZk);
 
     // Let's do it again
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     locFile = SOLR_HOME.resolve("solr-stress-new.xml");
     fileBytes = Files.readAllBytes(locFile);
@@ -279,7 +310,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
           "zk:/foo.xml"
         };
 
-    assertEquals(1, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(1, runTool(args, ZkCpTool.class));
   }
 
   @Test
@@ -287,10 +318,10 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     zkClient.makePath("/test/path", true);
 
     // test what happens when path arg "/" isn't the last one.
-    String[] args = new String[] {"ls", "/", "-r", "true", "-z", zkServer.getZkAddress()};
+    String[] args = new String[] {"ls", "/", "-r", "-z", zkServer.getZkAddress()};
 
     CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
-    assertEquals(0, CLITestHelper.runTool(args, runtime, ZkLsTool.class));
+    assertEquals(0, runTool(args, runtime, ZkLsTool.class));
 
     final String standardOutput2 = runtime.getOutput();
 
@@ -318,7 +349,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
           zkServer.getZkAddress()
         };
 
-    assertEquals(0, CLITestHelper.runTool(args, ConfigSetUploadTool.class));
+    assertEquals(0, runTool(args, ConfigSetUploadTool.class));
 
     assertTrue(zkClient.exists(ZkConfigSetService.CONFIGS_ZKNODE + "/" + confsetname));
     final Path confDir = ExternalPaths.TECHPRODUCTS_CONFIGSET;
@@ -349,7 +380,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
           zkServer.getZkAddress()
         };
 
-    assertEquals(0, CLITestHelper.runTool(args, ConfigSetDownloadTool.class));
+    assertEquals(0, runTool(args, ConfigSetDownloadTool.class));
 
     try (Stream<Path> filesStream = Files.list(destDir.resolve("conf"))) {
       List<Path> files = filesStream.toList();
@@ -395,7 +426,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     // test reset zk
     args = new String[] {"rm", "-r", "-z", zkServer.getZkAddress(), "zk:/configs/confsetone"};
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkRmTool.class));
+    assertEquals(0, runTool(args, ZkRmTool.class));
 
     assertEquals(0, zkClient.getChildren("/configs", null).size());
   }
@@ -412,7 +443,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
         new String[] {"cp", "-z", zkServer.getZkAddress(), "zk:" + getNode, localFile.toString()};
 
     CLITestHelper.TestingRuntime runtime = new CLITestHelper.TestingRuntime(true);
-    assertEquals(0, CLITestHelper.runTool(args, runtime, ZkCpTool.class));
+    assertEquals(0, runTool(args, runtime, ZkCpTool.class));
 
     final String standardOutput2 = runtime.getOutput();
     assertTrue(standardOutput2.startsWith("Copying from 'zk:/getNode'"));
@@ -438,7 +469,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     String[] args =
         new String[] {"cp", "-z", zkServer.getZkAddress(), "zk:" + getNode, localFile.toString()};
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     assertArrayEquals(data, Files.readAllBytes(localFile));
   }
@@ -457,7 +488,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     // Not setting --zk-host, will fall back to sysProp 'zkHost'
     String[] args = new String[] {"cp", "zk:" + getNode, file.toString()};
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
     assertArrayEquals(data, Files.readAllBytes(file));
   }
 
@@ -480,7 +511,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     String[] args =
         new String[] {"cp", "-z", zkServer.getZkAddress(), "zk:" + getNode, file.toString()};
 
-    assertEquals(0, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(0, runTool(args, ZkCpTool.class));
 
     assertArrayEquals(data, Files.readAllBytes(file));
   }
@@ -494,19 +525,72 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
     String[] args =
         new String[] {"cp", "-z", zkServer.getZkAddress(), "zk:" + getNode, file.toString()};
 
-    assertEquals(1, CLITestHelper.runTool(args, ZkCpTool.class));
+    assertEquals(1, runTool(args, ZkCpTool.class));
   }
 
   @Test
   public void testInvalidZKAddress() throws Exception {
 
-    String[] args = new String[] {"ls", "/", "-r", "-z", "----------:33332"};
+    String[] args = new String[] {"ls", "-r", "-z", "----------:33332", "/"};
+
+    assertEquals(1, runTool(args, ZkLsTool.class));
+  }
+
+  @Test
+  public void testMkroot() throws Exception {
+    String[] args = new String[] {"mkroot", "-z", zkServer.getZkAddress(), "/mkroot-test/a/b/c"};
+
+    assertEquals(0, runTool(args, ZkMkrootTool.class));
+    assertTrue("Created path should exist", zkClient.exists("/mkroot-test/a/b/c"));
 
-    assertEquals(1, CLITestHelper.runTool(args, ZkLsTool.class));
+    // --fail-on-exists with a value works for both commons-cli (hasArg) and picocli (arity 0..1)
+    args =
+        new String[] {
+          "mkroot", "--fail-on-exists", "true", "-z", zkServer.getZkAddress(), "/mkroot-test/a/b/c"
+        };
+    assertEquals(1, runTool(args, ZkMkrootTool.class));
+
+    // Without --fail-on-exists, creating an existing path should succeed
+    args = new String[] {"mkroot", "-z", zkServer.getZkAddress(), "/mkroot-test/a/b/c"};
+    assertEquals(0, runTool(args, ZkMkrootTool.class));
+  }
+
+  @Test
+  public void testMv() throws Exception {
+    zkClient.makePath("/mv-src", true);
+
+    String[] args = new String[] {"mv", "-z", zkServer.getZkAddress(), "/mv-src", "/mv-dst"};
+    assertEquals(0, runTool(args, ZkMvTool.class));
+    assertFalse("Source should be gone after mv", zkClient.exists("/mv-src"));
+    assertTrue("Destination should exist after mv", zkClient.exists("/mv-dst"));
+
+    // file: prefix should cause failure
+    args = new String[] {"mv", "-z", zkServer.getZkAddress(), "file:/mv-dst", "/mv-dst2"};
+    assertEquals(1, runTool(args, ZkMvTool.class));
+  }
+
+  @Test
+  public void testRm() throws Exception {
+    zkClient.makePath("/rm-test/child", true);
+
+    // Should fail without --recursive when node has children
+    String[] args = new String[] {"rm", "-z", zkServer.getZkAddress(), "/rm-test"};
+    assertEquals(1, runTool(args, ZkRmTool.class));
+    assertTrue("Node should still exist after failed rm", zkClient.exists("/rm-test"));
+
+    // Should succeed with --recursive
+    args = new String[] {"rm", "-r", "-z", zkServer.getZkAddress(), "/rm-test"};
+    assertEquals(0, runTool(args, ZkRmTool.class));
+    assertFalse("Node should be gone after rm -r", zkClient.exists("/rm-test"));
+
+    // Removing root should fail
+    args = new String[] {"rm", "-r", "-z", zkServer.getZkAddress(), "zk:/"};
+    assertEquals(1, runTool(args, ZkRmTool.class));
   }
 
   @Test
   public void testSetClusterProperty() throws Exception {
+    // ClusterTool is not a picocli ZK subcommand; always use the commons-cli path.
     ClusterProperties properties = new ClusterProperties(zkClient);
     // add property urlScheme=http
     String[] args =
@@ -539,7 +623,7 @@ public class ZkSubcommandsTest extends SolrTestCaseJ4 {
         VMParamsZkCredentialsInjector.DEFAULT_DIGEST_READONLY_PASSWORD_VM_PARAM_NAME, "pass");
 
     String[] args = new String[] {"updateacls", "/", "-z", zkServer.getZkAddress()};
-    assertEquals(0, CLITestHelper.runTool(args, UpdateACLTool.class));
+    assertEquals(0, runTool(args, UpdateACLTool.class));
 
     boolean excepted = false;
     try (SolrZkClient zkClient =
diff --git a/solr/cross-dc-manager/gradle.lockfile b/solr/cross-dc-manager/gradle.lockfile
index 54cdbd68e9c..45ec6a33532 100644
--- a/solr/cross-dc-manager/gradle.lockfile
+++ b/solr/cross-dc-manager/gradle.lockfile
@@ -41,6 +41,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/licenses/picocli-4.7.6.jar.sha1 b/solr/licenses/picocli-4.7.6.jar.sha1
new file mode 100644
index 00000000000..c63f30646a6
--- /dev/null
+++ b/solr/licenses/picocli-4.7.6.jar.sha1
@@ -0,0 +1 @@
+77c2cb87814b6a03d431fc856024a9f8ff605ad4
diff --git a/solr/licenses/picocli-LICENSE-ASL.txt b/solr/licenses/picocli-LICENSE-ASL.txt
new file mode 100644
index 00000000000..9c8f3ea0871
--- /dev/null
+++ b/solr/licenses/picocli-LICENSE-ASL.txt
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "{}"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright {yyyy} {name of copyright owner}
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
\ No newline at end of file
diff --git a/solr/licenses/picocli-NOTICE.txt b/solr/licenses/picocli-NOTICE.txt
new file mode 100644
index 00000000000..d77f6f044d4
--- /dev/null
+++ b/solr/licenses/picocli-NOTICE.txt
@@ -0,0 +1,2 @@
+Picocli is licensed under the Apache License, Version 2.0.
+Copyright (c) 2015-2025 Remko Popma and contributors.
\ No newline at end of file
diff --git a/solr/modules/analysis-extras/gradle.lockfile b/solr/modules/analysis-extras/gradle.lockfile
index e91e42c47b4..26c87933bec 100644
--- a/solr/modules/analysis-extras/gradle.lockfile
+++ b/solr/modules/analysis-extras/gradle.lockfile
@@ -36,6 +36,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/clustering/gradle.lockfile b/solr/modules/clustering/gradle.lockfile
index b9d9d5a87fc..ef4d68430c4 100644
--- a/solr/modules/clustering/gradle.lockfile
+++ b/solr/modules/clustering/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/cross-dc/gradle.lockfile b/solr/modules/cross-dc/gradle.lockfile
index 24495481763..2ab10c98d17 100644
--- a/solr/modules/cross-dc/gradle.lockfile
+++ b/solr/modules/cross-dc/gradle.lockfile
@@ -36,6 +36,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/cuvs/gradle.lockfile b/solr/modules/cuvs/gradle.lockfile
index d59a7033a54..11deecb721f 100644
--- a/solr/modules/cuvs/gradle.lockfile
+++ b/solr/modules/cuvs/gradle.lockfile
@@ -36,6 +36,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/extraction/gradle.lockfile b/solr/modules/extraction/gradle.lockfile
index cc71fe9ab65..4d6c15e1f0c 100644
--- a/solr/modules/extraction/gradle.lockfile
+++ b/solr/modules/extraction/gradle.lockfile
@@ -37,6 +37,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/gcs-repository/gradle.lockfile b/solr/modules/gcs-repository/gradle.lockfile
index 2f77c965bd5..72277260bd9 100644
--- a/solr/modules/gcs-repository/gradle.lockfile
+++ b/solr/modules/gcs-repository/gradle.lockfile
@@ -68,6 +68,7 @@ commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,run
 dev.cel:common:0.13.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 dev.cel:protobuf:0.13.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 dev.cel:runtime:0.13.1=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/jwt-auth/gradle.lockfile b/solr/modules/jwt-auth/gradle.lockfile
index f6f4c0069fb..befef0ddcc7 100644
--- a/solr/modules/jwt-auth/gradle.lockfile
+++ b/solr/modules/jwt-auth/gradle.lockfile
@@ -49,6 +49,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/langid/gradle.lockfile b/solr/modules/langid/gradle.lockfile
index fb47a8835bb..e12a480f401 100644
--- a/solr/modules/langid/gradle.lockfile
+++ b/solr/modules/langid/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.azagniotov:language-detection:12.5.2=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,testCompileClasspath,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/language-models/gradle.lockfile b/solr/modules/language-models/gradle.lockfile
index f17c0ff380f..de27ddf7ba2 100644
--- a/solr/modules/language-models/gradle.lockfile
+++ b/solr/modules/language-models/gradle.lockfile
@@ -47,6 +47,7 @@ dev.langchain4j:langchain4j-hugging-face:1.19.0-beta29=jarValidation,runtimeClas
 dev.langchain4j:langchain4j-mistral-ai:1.19.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 dev.langchain4j:langchain4j-ollama:1.19.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
 dev.langchain4j:langchain4j-open-ai:1.19.0=jarValidation,runtimeClasspath,runtimeLibs,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/ltr/gradle.lockfile b/solr/modules/ltr/gradle.lockfile
index ae0b8966b7f..325a1adec58 100644
--- a/solr/modules/ltr/gradle.lockfile
+++ b/solr/modules/ltr/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/opentelemetry/gradle.lockfile b/solr/modules/opentelemetry/gradle.lockfile
index d843c336814..ddead3d2ea5 100644
--- a/solr/modules/opentelemetry/gradle.lockfile
+++ b/solr/modules/opentelemetry/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/s3-repository/gradle.lockfile b/solr/modules/s3-repository/gradle.lockfile
index 5976107074c..412de8dfc33 100644
--- a/solr/modules/s3-repository/gradle.lockfile
+++ b/solr/modules/s3-repository/gradle.lockfile
@@ -38,6 +38,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/scripting/gradle.lockfile b/solr/modules/scripting/gradle.lockfile
index fba7ed1bb84..f6065b9c3ad 100644
--- a/solr/modules/scripting/gradle.lockfile
+++ b/solr/modules/scripting/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/modules/sql/gradle.lockfile b/solr/modules/sql/gradle.lockfile
index 605bb870f3d..9266d62515a 100644
--- a/solr/modules/sql/gradle.lockfile
+++ b/solr/modules/sql/gradle.lockfile
@@ -37,6 +37,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,runtimeLibs,solrPlatfor
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,runtimeLibs,solrPlatformLibs,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/packaging/build.gradle b/solr/packaging/build.gradle
index 4c4ad6de114..f81bc7ac295 100644
--- a/solr/packaging/build.gradle
+++ b/solr/packaging/build.gradle
@@ -285,11 +285,13 @@ tasks.register('downloadBats') {
   }
 }
 
-task integrationTests(type: BatsTask) {
-  dependsOn installFullDist
-  dependsOn downloadBats
+// Shared configuration for the BATS suites. outputName keeps each suite's Solr home, logs and
+// reports apart; usePicocli selects the experimental CLI implementation for the run.
+def configureBatsTask(BatsTask task, String outputName, boolean usePicocli) {
+  task.dependsOn installFullDist
+  task.dependsOn downloadBats
 
-  def integrationTestOutput = "$buildDir/test-output"
+  def integrationTestOutput = "$buildDir/$outputName"
   def solrHome = "$integrationTestOutput/solr-home"
   def solrTestFailuresDir = "$integrationTestOutput/failure-snapshots"
   var solrPort = Integer
@@ -304,37 +306,64 @@ task integrationTests(type: BatsTask) {
   }
 
   if (!Boolean.parseBoolean((String) project.findProperty('solr.bats.nightly'))) {
-    filterTags = '!nightly'
+    task.filterTags = '!nightly'
   }
 
-  inputs.dir(distDir)
-  inputs.dir('test')  // Track .bats test files and helper as inputs
-  outputs.dir(integrationTestOutput)
+  task.reportDir = integrationTestOutput
+  task.inputs.dir(distDir)
+  task.inputs.dir('test')  // Track .bats test files and helper as inputs
+  task.outputs.dir(integrationTestOutput)
 
-  doFirst {
+  task.doFirst {
     delete integrationTestOutput
     mkdir integrationTestOutput
     mkdir solrHome
     mkdir solrTestFailuresDir
 
     // TODO - if quiet then don't tee
-    standardOutput = new TeeOutputStream(System.out,
+    task.standardOutput = new TeeOutputStream(System.out,
         new FileOutputStream("$integrationTestOutput/test-output.txt"))
     println("Running BATS tests with Solr base port ${solrPort}")
   }
 
-  environment SOLR_TIP: distDir.toString()
-  environment SOLR_HOME: solrHome
-  environment SOLR_PID_DIR: solrHome
-  environment SOLR_PORT_LISTEN: solrPort
-  environment SOLR_PORT: solrPort
-  environment SOLR2_PORT: solrPort + 1
-  environment SOLR3_PORT: solrPort + 2
-  environment ZK_PORT: solrPort + 1000
-  environment SOLR_LOGS_DIR: "$solrHome/logs"
-  environment TEST_OUTPUT_DIR: integrationTestOutput
-  environment TEST_FAILURE_DIR: solrTestFailuresDir
-  environment BATS_LIB_PREFIX: batsLibsDir
+  task.environment SOLR_TIP: distDir.toString()
+  task.environment SOLR_HOME: solrHome
+  task.environment SOLR_PID_DIR: solrHome
+  task.environment SOLR_PORT_LISTEN: solrPort
+  task.environment SOLR_PORT: solrPort
+  task.environment SOLR2_PORT: solrPort + 1
+  task.environment SOLR3_PORT: solrPort + 2
+  task.environment ZK_PORT: solrPort + 1000
+  task.environment SOLR_LOGS_DIR: "$solrHome/logs"
+  task.environment TEST_OUTPUT_DIR: integrationTestOutput
+  task.environment TEST_FAILURE_DIR: solrTestFailuresDir
+  task.environment BATS_LIB_PREFIX: batsLibsDir
+  if (usePicocli) {
+    task.environment SOLR_PICOCLI: 'true'
+  }
+}
+
+task integrationTests(type: BatsTask) {
+  configureBatsTask(it, 'test-output', false)
+}
+
+// The same suites run against the experimental picocli CLI. Deliberately a curated list rather
+// than the whole suite, so the run stays short. A change that ports another tool to picocli
+// should add that tool's .bats file here.
+task integrationTestsPicocli(type: BatsTask) {
+  description = 'Runs the BATS suites for the migrated commands against the experimental picocli CLI.'
+  configureBatsTask(it, 'test-output-picocli', true)
+  // A suite belongs here only when every command it invokes has been migrated, not merely the
+  // command under test. test_auth.bats, for example, uses `solr assert` to wait for startup, so
+  // it cannot join until the assert command is migrated. test_help.bats asserts the exact wording
+  // of the commons-cli help output, which the picocli implementation words differently.
+  testFiles = [
+      'test/test_version.bats',
+      'test/test_status.bats',
+      'test/test_create.bats',
+      'test/test_delete_collection.bats',
+      'test/test_zk.bats',
+  ]
 }
 
 abstract class BatsTask extends Exec {
@@ -350,6 +379,10 @@ abstract class BatsTask extends Exec {
   @Optional
   String filterTags = null
 
+  /** Where to write the junit report. Defaults to the project's test-output directory. */
+  @Internal
+  String reportDir = null
+
   @Option(option = "tests", description = "Sets test cases to be included")
   public void setTestNamePatterns(List<String> tests) {
     // TODO: bats --filter <regex>
@@ -369,7 +402,7 @@ abstract class BatsTask extends Exec {
       batsArgs += ['--filter-tags', filterTags]
     }
     batsArgs +=
-        ['-T', '--print-output-on-failure', '--report-formatter', 'junit', '--output', "$project.buildDir/test-output"]
+        ['-T', '--print-output-on-failure', '--report-formatter', 'junit', '--output', reportDir ?: "$project.buildDir/test-output"]
     // Note: tests to run must be listed after all other arguments
     batsArgs += testFiles.empty ? [testDir] : testFiles
     setArgs(batsArgs)
diff --git a/solr/packaging/test/test_create.bats b/solr/packaging/test/test_create.bats
index a261938c23d..10b934dc523 100644
--- a/solr/packaging/test/test_create.bats
+++ b/solr/packaging/test/test_create.bats
@@ -42,5 +42,5 @@ teardown() {
 
 @test "multiple connection options are prevented" {
   run solr create -c COLL_NAME2 --solr-url http://localhost:${SOLR_PORT} -z localhost:${ZK_PORT}
-  assert_output --partial "The option 'z' was specified but an option from this group has already been selected: 'solr-url'"
+  assert_output --regexp "(mutually exclusive|already been selected).*(--solr-url|--zk-host|-z|-s)"
 }
diff --git a/solr/packaging/test/test_status.bats b/solr/packaging/test/test_status.bats
index c03e07bfc60..7ec9d865711 100644
--- a/solr/packaging/test/test_status.bats
+++ b/solr/packaging/test/test_status.bats
@@ -53,7 +53,7 @@ teardown() {
 
 @test "multiple connection options are prevented" {
   run solr status --port ${SOLR_PORT} --solr-url http://localhost:${SOLR_PORT}
-  assert_output --partial "The option 'solr-url' was specified but an option from this group has already been selected: 'p'"
+  assert_output --regexp "(-s|solr-url).*(mutually exclusive|already been selected)"
 }
 
 @test "status with invalid --solr-url from user" {
diff --git a/solr/packaging/test/test_zk.bats b/solr/packaging/test/test_zk.bats
index e2732354799..bec659c7624 100644
--- a/solr/packaging/test/test_zk.bats
+++ b/solr/packaging/test/test_zk.bats
@@ -48,15 +48,18 @@ teardown() {
 
 @test "long help" {
  run solr zk -h
- assert_output --partial "bin/solr zk ls"
- assert_output --partial "bin/solr zk updateacls"
+ # TODO: Long help is different in picocli
+ #assert_output --partial "bin/solr zk ls"
+ #assert_output --partial "bin/solr zk updateacls"
  assert_output --partial "Pass --help or -h after any COMMAND"
+ assert_output --partial "updateacls"
 }
 
-@test "running subcommands with zk is prevented" {
- run solr ls / -z localhost:${ZK_PORT}
- assert_output --partial "You must invoke this subcommand using the zk command"
-}
+# TODO: Picocli fails in a different way when a subcommand is not invoked using the zk command
+#@test "running subcommands with zk is prevented" {
+# run solr ls / -z localhost:${ZK_PORT}
+# assert_output --partial "You must invoke this subcommand using the zk command"
+#}
 
 @test "listing out files" {
   run solr zk ls / -z localhost:${ZK_PORT} --recursive
diff --git a/solr/server/gradle.lockfile b/solr/server/gradle.lockfile
index a014224ab04..8511cd9a4b0 100644
--- a/solr/server/gradle.lockfile
+++ b/solr/server/gradle.lockfile
@@ -33,6 +33,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,solrCore
 commons-cli:commons-cli:1.11.0=jarValidation,runtimeClasspath,solrCore
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,solrCore
 commons-io:commons-io:2.22.0=jarValidation,runtimeClasspath,solrCore
+info.picocli:picocli:4.7.6=jarValidation,runtimeClasspath,solrCore
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,runtimeClasspath,solrCore
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/solr-ref-guide/README.adoc b/solr/solr-ref-guide/README.adoc
index 642aaa823e6..df958e84f61 100644
--- a/solr/solr-ref-guide/README.adoc
+++ b/solr/solr-ref-guide/README.adoc
@@ -20,6 +20,12 @@ This is the source for the Solr Reference Guide.
 
 Raw content is stored in Asciidoc (`.adoc`) formatted files in the `modules/` directory.
 
+One directory is an exception: `modules/deployment-guide/pages/cli/` holds the `bin/solr` command reference pages,
+which are generated from the picocli annotations in the Java source by `./gradlew :solr:solr-ref-guide:generateCliDocs`.
+Every `solr-*.adoc` file there is generated and must not be edited by hand; edit the annotations and regenerate.
+Only `index.adoc` in that directory is hand-written.
+See `../../dev-docs/ref-guide/antora.adoc` for details.
+
 To build the Ref Guide, run `./gradlew buildLocalSite`.
 This will build the HTML files and validate that page references and internal links work properly.
 You can find the home page of the generated site under `build/site/index.html`
diff --git a/solr/solr-ref-guide/build.gradle b/solr/solr-ref-guide/build.gradle
index 1fc8634a6e2..80ee1136386 100644
--- a/solr/solr-ref-guide/build.gradle
+++ b/solr/solr-ref-guide/build.gradle
@@ -16,6 +16,8 @@
  */
 
 import groovy.json.JsonOutput
+import javax.inject.Inject
+import org.gradle.process.ExecOperations
 import org.apache.tools.ant.util.TeeOutputStream
 
 apply plugin: 'java'
@@ -54,6 +56,7 @@ if (propertyOrEnvOrDefault('refguide.include',
     'SOLR_REF_GUIDE_INCLUDE',
     "${defaultRefGuideInclude}").toBoolean()) {
   check.dependsOn 'checkSiteLinks'
+  check.dependsOn 'checkCliDocsUpToDate'
   assemble.dependsOn 'buildLocalSite'
 }
 
@@ -62,11 +65,16 @@ configurations {
   officialPlaybook
   localPlaybook
   localJavadocs
+  cliClasspath {
+    canBeResolved = true
+    canBeConsumed = false
+  }
 }
 
 dependencies {
   localJavadocs project(path: ":solr:documentation", configuration: 'javadocs')
   localJavadocs project(path: ":solr:documentation", configuration: 'site')
+  cliClasspath project(path: ':solr:core', configuration: 'cliDocsRuntime')
 }
 
 ext {
@@ -557,6 +565,392 @@ task buildOfficialSite(type: NpxTask) {
   }
 }
 
+/*
+ CLI Documentation Generation from Picocli Annotations
+ Generates per-command AsciiDoc reference pages for the Antora ref guide.
+ Usage:
+   ./gradlew :solr:solr-ref-guide:generateCliDocs   -- regenerate pages/cli/
+   ./gradlew :solr:solr-ref-guide:checkCliDocsUpToDate -- verify in-sync (also run by check)
+ */
+
+def ASF_LICENSE_HEADER = """\
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+"""
+
+def EXPERIMENTAL_NOTICE = """\
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+"""
+
+def DO_NOT_EDIT_NOTICE = """\
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+"""
+
+// Convert a generated filename stem (e.g. "solr-zk-ls") to an Antora page title
+// e.g. "solr-zk-ls" -> "bin/solr zk ls"
+String cliFileNameToTitle(String baseName) {
+  def parts = baseName.split('-')
+  return "bin/solr " + parts.drop(1).join(' ')
+}
+
+// Return the short nav label for a command file (last segment of dash-separated name)
+// e.g. "solr-zk-ls" -> "ls"
+String cliFileNameToNavLabel(String baseName) {
+  return baseName.split('-').last()
+}
+
+// Strip "[...]" (optional groups) and "(...)" (required groups whose members
+// aren't *individually* required), repeating until no inner groups remain.
+// Tokens left in the result are the ones the user must always provide.
+String stripSynopsisGroups(String text) {
+  String s = text
+  String prev = null
+  while (s != prev) {
+    prev = s
+    s = s.replaceAll(/\[[^\[\]]*\]/, '')
+    s = s.replaceAll(/\([^()]*\)/, '')
+  }
+  return s
+}
+
+Set<String> extractRequiredTokens(String synopsis, java.util.regex.Pattern pattern) {
+  def result = new LinkedHashSet<String>()
+  def m = stripSynopsisGroups(synopsis) =~ pattern
+  while (m.find()) { result.add(m.group(1)) }
+  return result
+}
+
+// Parse the wrapped synopsis to find required (unbracketed) options and positional
+// args, then prepend a "*(required)*" marker to their description lines in the
+// Options and Arguments sections. Picocli's ManPageGenerator doesn't emit a
+// per-option required marker, so we use the synopsis bracketing as the source of
+// truth instead of reaching into the CommandSpec.
+// See https://github.com/remkop/picocli/issues/2519.
+// Normalize a generated CLI page before comparing committed vs freshly generated content.
+// Absorbs cosmetic churn (line endings, trailing whitespace, blank-line runs and the
+// generator's tag comments) so that a picocli upgrade does not fail `check` repo-wide
+// for a purely presentational difference.
+String normalizeCliDoc(String content) {
+  return content
+    .replace('\r\n', '\n')
+    .replaceAll(/(?m)^\/\/ (tag|end)::picocli-generated-[^\n]*$/, '')
+    .replaceAll(/(?m)[ \t]+$/, '')
+    .replaceAll(/\n{3,}/, '\n\n')
+    .trim()
+}
+
+String markRequiredOptionsAndArgs(String content) {
+  final String MARKER = '*(required)*'
+  final def OPTION_NAME = ~/(-{1,2}[a-zA-Z][a-zA-Z0-9-]*)/
+  final def POSITIONAL_NAME = ~/(<[^<>\s]+>)/
+
+  def synopsisMatch = content =~ /(?s)== Synopsis\n\n\.{4}\n(.*?)\n\.{4}/
+  if (!synopsisMatch.find()) {
+    // Required-ness is inferred by re-parsing the synopsis picocli generated, because
+    // ManPageGenerator emits no per-option required marker (picocli #2519). If the
+    // synopsis format ever changes, fail loudly rather than silently publishing pages
+    // with every '*(required)*' marker missing.
+    throw new GradleException(
+      "Could not locate the Synopsis block while post-processing a generated CLI page. " +
+      "The picocli ManPageGenerator output format has probably changed; " +
+      "markRequiredOptionsAndArgs in solr-ref-guide/build.gradle needs updating.")
+  }
+
+  def synopsisText = synopsisMatch.group(1).replaceAll(/\s+/, ' ')
+  def requiredOpts = extractRequiredTokens(synopsisText, OPTION_NAME)
+  def requiredArgs = extractRequiredTokens(synopsisText, POSITIONAL_NAME)
+
+  if (!requiredOpts.isEmpty()) {
+    content = content.replaceAll(/(?m)^(\*-[^\n]+::)\n( +)([^\n]+)/) { List<String> m ->
+      def (label, indent, descLine) = [m[1], m[2], m[3]]
+      if (descLine.startsWith(MARKER)) return m[0]
+      def beforeEq = label.replaceAll(/[*_]/, '').split('=', 2)[0]
+      def names = (beforeEq =~ OPTION_NAME).collect { it[1] }
+      names.any { requiredOpts.contains(it) }
+        ? "${label}\n${indent}${MARKER} ${descLine}" : m[0]
+    }
+  }
+
+  if (!requiredArgs.isEmpty()) {
+    content = content.replaceAll(/(?m)^_(<[^<>\n]+>)_::\n( +)([^\n]+)/) { List<String> m ->
+      def (name, indent, descLine) = [m[1], m[2], m[3]]
+      if (descLine.startsWith(MARKER)) return m[0]
+      requiredArgs.contains(name)
+        ? "_${name}_::\n${indent}${MARKER} ${descLine}" : m[0]
+    }
+  }
+  return content
+}
+
+// Post-process a raw ManPageGenerator AsciiDoc file for Antora compatibility.
+// The title lives inside the man-section-header block, so we replace the whole
+// header block with an Antora-compatible page title + attributes.
+String postProcessCliManPage(File rawFile, String asfHeader, String doNotEditNotice, String experimentalNotice) {
+  def content = rawFile.text
+  def baseName = rawFile.name.replace('.adoc', '')
+  def newTitle = cliFileNameToTitle(baseName)
+
+  // Replace the entire man-section-header block (which contains :doctype:manpage, the page title
+  // "= solr-start(1)", etc.) with our Antora-compatible title and page attributes.
+  content = content.replaceAll(
+    /(?s)\/\/ tag::picocli-generated-man-section-header\[\].*?\/\/ end::picocli-generated-man-section-header\[\]\n\n?/,
+    "= ${newTitle}\n:page-toclevels: 2\n\n")
+
+  // Fix xref paths: ManPageGenerator generates "xref:solr-*.adoc" but in Antora xrefs are resolved
+  // from the module's pages/ root, so pages/cli/ files need the "cli/" prefix.
+  content = content.replace('xref:solr-', 'xref:cli/solr-')
+
+  // ManPageGenerator renders the command's file-stem as "solr-start" (parent-dash-child) in the
+  // Name section. Replace occurrences of that with the correct spaced form "bin/solr start".
+  // Use a negative lookahead (?!-) to avoid replacing "solr-zk" inside xref paths like
+  // "xref:cli/solr-zk-downconfig.adoc" where it is a prefix of a longer command name.
+  content = content.replaceAll("${baseName}(?!-)", newTitle)
+
+  // Wrap the synopsis paragraph in a literal block to preserve line breaks and indentation.
+  // ManPageGenerator generates a plain paragraph with picocli AsciiDoc marks (*bold*, _italic_)
+  // which Asciidoctor collapses to a single line. A `....` literal block preserves whitespace
+  // but does not render inline formatting, so we strip the marks for a clean monospace output.
+  content = content.replaceAll(
+    /(?s)(== Synopsis\n\n)(.*?)(\n\n\/\/ end::picocli-generated-man-section-synopsis\[\])/
+  ) { List<String> m ->
+    def synopsis = m[2]
+      .replaceAll(/\*([^*]+)\*/, '$1')  // *bold* → plain
+      .replaceAll(/_([^_]+)_/, '$1')    // _italic_ → plain
+      .replaceAll(/(?m)^solr /, 'bin/solr ')  // qualified name "solr start" → "bin/solr start"
+      .replaceAll(/(?m)^( +)/, '    $1')     // re-align wrapped lines: add 4 spaces ("bin/" length)
+    "${m[1]}....\n${synopsis}\n....${m[3]}"
+  }
+
+  content = markRequiredOptionsAndArgs(content)
+
+  // Strip the outer full-manpage tag wrappers
+  content = content.replace("// tag::picocli-generated-full-manpage[]\n", '')
+  content = content.replace("// end::picocli-generated-full-manpage[]\n", '')
+
+  // The Name section repeats the Description verbatim for every command, and the page title
+  // already names the command, so drop it.
+  content = content.replaceAll(
+    /(?s)\/\/ tag::picocli-generated-man-section-name\[\].*?\/\/ end::picocli-generated-man-section-name\[\]\n\n?/, '')
+
+  // Drop tag comment pairs left empty because the command has no such section.
+  content = content.replaceAll(
+    /(?m)^\/\/ tag::(picocli-generated-[^\[]+)\[\]\n\/\/ end::\1\[\]\n\n?/, '')
+
+  // picocli emits man-page style headings; match the house style used by every other page.
+  content = content.replaceAll(/(?m)^==\s+(.*?):\s*$/, '== $1')
+
+  // Inserted last, after the blanket 'xref:solr-' -> 'xref:cli/solr-' rewrite above, which would
+  // otherwise redirect this notice's own link into pages/cli/.
+  content = content.replace(":page-toclevels: 2\n", ":page-toclevels: 2\n\n" + experimentalNotice)
+
+  return asfHeader + doNotEditNotice + "\n" + content.stripTrailing() + "\n"
+}
+
+// Build the CLI sub-page nav entries from the generated .adoc files in destDir.
+// Returns a list of nav lines like "** xref:cli/solr-start.adoc[start]"
+// Build the "Migrated Commands" table for the hand-written cli/index.adoc, so that the list of
+// migrated commands cannot drift from the set of generated pages.
+String buildCliCommandTable(File cliDir) {
+  def files = ((cliDir.listFiles() ?: []) as List<File>)
+    .findAll { it.name.startsWith('solr-') && it.name.endsWith('.adoc') }
+    .sort { it.name }
+  def rows = files.collect { file ->
+    def baseName = file.name.replace('.adoc', '')
+    "|xref:cli/${file.name}[${cliFileNameToTitle(baseName)}]"
+  }
+  return "[cols=\"1\",options=\"header\"]\n|===\n|Command\n\n" + rows.join('\n') + "\n|==="
+}
+
+List<String> buildCliNavEntries(File cliDir) {
+  // Sort so that parent commands always precede their children.
+  // The trick: append '\u0000' (NUL, ASCII 0) to the stem before sorting.
+  // NUL < '-' (ASCII 45), so "solr-zk\0" < "solr-zk-cp\0", placing the parent first.
+  def files = ((cliDir.listFiles() ?: []) as List<File>)
+    .findAll { it.name.startsWith('solr-') && it.name.endsWith('.adoc') }
+    .sort { (it.name.replace('.adoc', '') + '\u0000') }
+  def stems = files.collect { it.name.replace('.adoc', '') } as Set
+  return files.collect { file ->
+    def baseName = file.name.replace('.adoc', '')
+    def label = cliFileNameToNavLabel(baseName)
+    // Nest under the deepest ancestor that is itself a generated page. Counting dashes instead
+    // would mis-nest a command whose own name contains one, e.g. a future "snapshot-create"
+    // would be filed under a "solr-snapshot" page that does not exist.
+    def depth = 0
+    for (def stem = baseName; stem.contains('-'); ) {
+      stem = stem.substring(0, stem.lastIndexOf('-'))
+      if (stems.contains(stem)) {
+        depth++
+      }
+    }
+    // Generated pages hang off cli/index.adoc, which itself sits under the control script page,
+    // so the shallowest generated page is already three levels deep.
+    def stars = '*' * (depth + 3)
+    "${stars} xref:cli/${file.name}[${label}]"
+  }
+}
+
+// Run ManPageGenerator and return the set of generated files (excluding the top-level solr.adoc).
+// SolrCLI is annotated @Command(name="solr"), so ManPageGenerator writes all files flat into outputDir.
+interface CliDocsExecOperationsHolder {
+  @Inject
+  ExecOperations getExecOperations()
+}
+
+List<File> runManPageGenerator(File outputDir) {
+  // Project.javaexec was removed in Gradle 9; use injected ExecOperations instead
+  def execOps = project.objects.newInstance(CliDocsExecOperationsHolder).execOperations
+  execOps.javaexec {
+    classpath = configurations.cliClasspath
+    mainClass = 'picocli.codegen.docgen.manpage.ManPageGenerator'
+    args = ['--outdir', outputDir.absolutePath, 'org.apache.solr.cli.SolrCLI']
+  }
+
+  // Exclude solr.adoc (the top-level parent page — we don't publish it as a standalone page)
+  return (outputDir.listFiles() as List<File>)
+    .findAll { f -> f.name.endsWith('.adoc') && f.name != 'solr.adoc' }
+    .sort { it.name }
+}
+
+task generateCliDocs {
+  group = 'generation'
+  description = 'Regenerate CLI reference pages in pages/cli/ from picocli annotations.'
+
+  dependsOn ':solr:core:classes'
+
+  def rawOutputDir = file("${buildDir}/generated-cli-docs-raw")
+  def destDir = file("modules/deployment-guide/pages/cli")
+  def navFile = file("modules/deployment-guide/deployment-nav.adoc")
+
+  inputs.files(configurations.cliClasspath)
+  // destDir and navFile live in the source tree, so they are deliberately not declared
+  // as task outputs: Gradle's stale-output cleanup would be entitled to delete committed
+  // documentation. This is a developer-invoked regeneration task, so always run it.
+  // checkCliDocsUpToDate is the incremental half of this pair.
+  outputs.upToDateWhen { false }
+
+  doLast {
+    rawOutputDir.mkdirs()
+    destDir.mkdirs()
+
+    def rawFiles = runManPageGenerator(rawOutputDir)
+
+    rawFiles.each { rawFile ->
+      def processed = postProcessCliManPage(rawFile, ASF_LICENSE_HEADER, DO_NOT_EDIT_NOTICE, EXPERIMENTAL_NOTICE)
+      new File(destDir, rawFile.name).text = processed
+    }
+
+    // Refresh the command table in the hand-written landing page
+    def indexFile = new File(destDir, 'index.adoc')
+    if (indexFile.exists()) {
+      def indexContent = indexFile.text
+      if (indexContent.contains('// CLI-DOCS-TABLE-START')) {
+        def table = buildCliCommandTable(destDir)
+        indexFile.text = indexContent.replaceAll(
+          /(?s)\/\/ CLI-DOCS-TABLE-START.*?\/\/ CLI-DOCS-TABLE-END/,
+          "// CLI-DOCS-TABLE-START\n${table}\n// CLI-DOCS-TABLE-END")
+      } else {
+        logger.warn("cli/index.adoc does not contain // CLI-DOCS-TABLE-START marker; table not updated.")
+      }
+    }
+
+    // Update the CLI nav section in deployment-nav.adoc between marker comments
+    def navContent = navFile.text
+    if (navContent.contains('// CLI-DOCS-START')) {
+      def navEntries = buildCliNavEntries(destDir).join('\n')
+      def replacement = "// CLI-DOCS-START\n${navEntries}\n// CLI-DOCS-END"
+      navContent = navContent.replaceAll(/(?s)\/\/ CLI-DOCS-START.*?\/\/ CLI-DOCS-END/, replacement)
+      navFile.text = navContent
+    } else {
+      logger.warn("deployment-nav.adoc does not contain // CLI-DOCS-START marker; nav not updated.")
+    }
+
+    logger.lifecycle("Generated ${rawFiles.size()} CLI doc pages in ${destDir}")
+  }
+}
+
+task checkCliDocsUpToDate {
+  group = 'verification'
+  description = 'Verify that pages/cli/ CLI docs are in sync with picocli annotations.'
+
+  dependsOn ':solr:core:classes'
+
+  def rawOutputDir = file("${buildDir}/generated-cli-docs-check")
+  def committedDir = file("modules/deployment-guide/pages/cli")
+
+  def stampFile = file("${buildDir}/cli-docs-check.stamp")
+
+  inputs.files(configurations.cliClasspath)
+  inputs.dir(committedDir)
+  outputs.dir(rawOutputDir)
+  outputs.file(stampFile)
+
+  doLast {
+    rawOutputDir.mkdirs()
+
+    def rawFiles = runManPageGenerator(rawOutputDir)
+    def issues = []
+
+    rawFiles.each { rawFile ->
+      def processed = postProcessCliManPage(rawFile, ASF_LICENSE_HEADER, DO_NOT_EDIT_NOTICE, EXPERIMENTAL_NOTICE)
+      def committed = new File(committedDir, rawFile.name)
+      if (!committed.exists()) {
+        issues << "MISSING committed file (new command): ${rawFile.name}"
+      } else {
+        if (normalizeCliDoc(processed) != normalizeCliDoc(committed.text)) {
+          issues << "OUT OF DATE: ${rawFile.name}"
+        }
+      }
+    }
+
+    // Check for committed pages that are no longer generated (deleted commands)
+    if (committedDir.exists()) {
+      def generatedNames = rawFiles.collect { it.name }.toSet()
+      // Only sweep generated pages; hand-written pages such as index.adoc are not generated.
+      ((committedDir.listFiles() ?: []) as List<File>).findAll {
+        it.name.startsWith('solr-') && it.name.endsWith('.adoc')
+      }.each { committed ->
+        if (!generatedNames.contains(committed.name)) {
+          issues << "STALE (command removed): ${committed.name}"
+        }
+      }
+    }
+
+    if (!issues.isEmpty()) {
+      throw new GradleException(
+        "CLI docs are out of date. Run './gradlew :solr:solr-ref-guide:generateCliDocs' to regenerate.\n" +
+        "This also applies after changing the picocli version, since the generated wording\n" +
+        "can change without any annotation having been touched.\n" +
+        "Issues:\n" + issues.collect { "  - ${it}" }.join('\n'))
+    }
+    stampFile.parentFile.mkdirs()
+    stampFile.text = "CLI docs verified at ${new Date()}\n"
+    logger.lifecycle("CLI docs are up to date.")
+  }
+}
+
+
 /*
  Compiling, Testing and Validation for the java examples in the Solr Ref Guide
  */
diff --git a/solr/solr-ref-guide/gradle.lockfile b/solr/solr-ref-guide/gradle.lockfile
index 224b2ca8b66..ef3e35a610b 100644
--- a/solr/solr-ref-guide/gradle.lockfile
+++ b/solr/solr-ref-guide/gradle.lockfile
@@ -3,176 +3,178 @@
 # This file is expected to be part of source control.
 # To regenerate this file, run: ./gradlew :solr:solr-ref-guide:dependencies --write-locks
 com.carrotsearch.randomizedtesting:randomizedtesting-runner:2.9.1=testCompileClasspath,testRuntimeClasspath
-com.carrotsearch:hppc:0.11.1=testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-annotations:2.22=testCompileClasspath,testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-core:2.22.3=testRuntimeClasspath
-com.fasterxml.jackson.core:jackson-databind:2.22.3=testRuntimeClasspath
-com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.22.3=testRuntimeClasspath
-com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.3=testRuntimeClasspath
-com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.22.3=testRuntimeClasspath
-com.fasterxml.jackson:jackson-bom:2.22.3=testCompileClasspath,testRuntimeClasspath
-com.fasterxml.woodstox:woodstox-core:7.3.0=testRuntimeClasspath
-com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,errorprone,testAnnotationProcessor,testRuntimeClasspath
+com.carrotsearch:hppc:0.11.1=cliClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-annotations:2.22=cliClasspath,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-core:2.22.3=cliClasspath,testRuntimeClasspath
+com.fasterxml.jackson.core:jackson-databind:2.22.3=cliClasspath,testRuntimeClasspath
+com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.22.3=cliClasspath,testRuntimeClasspath
+com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.22.3=cliClasspath,testRuntimeClasspath
+com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations:2.22.3=cliClasspath,testRuntimeClasspath
+com.fasterxml.jackson:jackson-bom:2.22.3=cliClasspath,testCompileClasspath,testRuntimeClasspath
+com.fasterxml.woodstox:woodstox-core:7.3.0=cliClasspath,testRuntimeClasspath
+com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testRuntimeClasspath
 com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.auto.value:auto-value-annotations:1.11.1=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.auto:auto-common:1.2.2=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.errorprone:error_prone_annotation:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor
-com.google.errorprone:error_prone_annotations:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.errorprone:error_prone_annotations:2.50.0=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 com.google.errorprone:error_prone_check_api:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.errorprone:error_prone_core:2.50.0=annotationProcessor,errorprone,testAnnotationProcessor
 com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,errorprone,testAnnotationProcessor
-com.google.guava:failureaccess:1.0.3=annotationProcessor,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
-com.google.guava:guava:33.7.1-jre=annotationProcessor,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
-com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
-com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.guava:failureaccess:1.0.3=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.guava:guava:33.7.1-jre=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 com.google.protobuf:protobuf-java:4.36.2=annotationProcessor,errorprone,testAnnotationProcessor,testRuntimeClasspath
 com.googlecode.json-simple:json-simple:1.1.1=testRuntimeClasspath
-com.j256.simplemagic:simplemagic:1.17=testRuntimeClasspath
-com.jayway.jsonpath:json-path:3.0.0=testRuntimeClasspath
-com.tdunning:t-digest:3.3=testRuntimeClasspath
-commons-cli:commons-cli:1.11.0=testRuntimeClasspath
-commons-codec:commons-codec:1.22.1=testRuntimeClasspath
-commons-io:commons-io:2.22.0=testCompileClasspath,testRuntimeClasspath
-io.dropwizard.metrics:metrics-core:4.2.39=testRuntimeClasspath
+com.j256.simplemagic:simplemagic:1.17=cliClasspath,testRuntimeClasspath
+com.jayway.jsonpath:json-path:3.0.0=cliClasspath,testRuntimeClasspath
+com.tdunning:t-digest:3.3=cliClasspath,testRuntimeClasspath
+commons-cli:commons-cli:1.11.0=cliClasspath,testRuntimeClasspath
+commons-codec:commons-codec:1.22.1=cliClasspath,testRuntimeClasspath
+commons-io:commons-io:2.22.0=cliClasspath,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli-codegen:4.7.6=cliClasspath
+info.picocli:picocli:4.7.6=cliClasspath,testRuntimeClasspath
+io.dropwizard.metrics:metrics-core:4.2.39=cliClasspath,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
-io.netty:netty-buffer:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-codec-base:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-common:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-handler:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-resolver:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-tcnative-boringssl-static:2.0.84.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-tcnative-classes:2.0.84.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport-classes-epoll:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport-native-epoll:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport-native-unix-common:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.netty:netty-transport:4.2.18.Final=testCompileClasspath,testRuntimeClasspath
-io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.31.1-alpha=testRuntimeClasspath
-io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.31.1=testRuntimeClasspath
-io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:2.31.1-alpha=testRuntimeClasspath
-io.opentelemetry.semconv:opentelemetry-semconv:1.43.0=testRuntimeClasspath
-io.opentelemetry:opentelemetry-api-incubator:1.65.0-alpha=testRuntimeClasspath
-io.opentelemetry:opentelemetry-api:1.65.0=testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-common:1.65.0=testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-context:1.65.0=testCompileClasspath,testRuntimeClasspath
-io.opentelemetry:opentelemetry-exporter-prometheus:1.65.0-alpha=testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk-common:1.65.0=testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk-metrics:1.65.0=testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk-trace:1.65.0=testRuntimeClasspath
-io.opentelemetry:opentelemetry-sdk:1.65.0=testRuntimeClasspath
-io.prometheus:prometheus-metrics-config:1.8.0=testRuntimeClasspath
-io.prometheus:prometheus-metrics-exposition-formats:1.8.0=testRuntimeClasspath
-io.prometheus:prometheus-metrics-exposition-textformats:1.8.0=testRuntimeClasspath
-io.prometheus:prometheus-metrics-model:1.8.0=testRuntimeClasspath
-io.sgr:s2-geometry-library-java:1.0.0=testRuntimeClasspath
-io.swagger.core.v3:swagger-annotations-jakarta:2.2.54=testCompileClasspath,testRuntimeClasspath
-jakarta.activation:jakarta.activation-api:2.1.3=testRuntimeClasspath
-jakarta.annotation:jakarta.annotation-api:3.0.0=testRuntimeClasspath
-jakarta.inject:jakarta.inject-api:2.0.1=testRuntimeClasspath
-jakarta.servlet:jakarta.servlet-api:6.1.0=testRuntimeClasspath
-jakarta.validation:jakarta.validation-api:3.1.0=testRuntimeClasspath
-jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=testRuntimeClasspath
-jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=testRuntimeClasspath
+io.netty:netty-buffer:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-codec-base:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-common:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-handler:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-resolver:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-tcnative-boringssl-static:2.0.84.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-tcnative-classes:2.0.84.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport-classes-epoll:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport-native-epoll:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport-native-unix-common:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.netty:netty-transport:4.2.18.Final=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry.instrumentation:opentelemetry-instrumentation-api-incubator:2.31.1-alpha=cliClasspath,testRuntimeClasspath
+io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:2.31.1=cliClasspath,testRuntimeClasspath
+io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry:2.31.1-alpha=cliClasspath,testRuntimeClasspath
+io.opentelemetry.semconv:opentelemetry-semconv:1.43.0=cliClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-api-incubator:1.65.0-alpha=cliClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-api:1.65.0=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-common:1.65.0=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-context:1.65.0=cliClasspath,testCompileClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-exporter-prometheus:1.65.0-alpha=cliClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk-common:1.65.0=cliClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk-metrics:1.65.0=cliClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk-trace:1.65.0=cliClasspath,testRuntimeClasspath
+io.opentelemetry:opentelemetry-sdk:1.65.0=cliClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-config:1.8.0=cliClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-exposition-formats:1.8.0=cliClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-exposition-textformats:1.8.0=cliClasspath,testRuntimeClasspath
+io.prometheus:prometheus-metrics-model:1.8.0=cliClasspath,testRuntimeClasspath
+io.sgr:s2-geometry-library-java:1.0.0=cliClasspath,testRuntimeClasspath
+io.swagger.core.v3:swagger-annotations-jakarta:2.2.54=cliClasspath,testCompileClasspath,testRuntimeClasspath
+jakarta.activation:jakarta.activation-api:2.1.3=cliClasspath,testRuntimeClasspath
+jakarta.annotation:jakarta.annotation-api:3.0.0=cliClasspath,testRuntimeClasspath
+jakarta.inject:jakarta.inject-api:2.0.1=cliClasspath,testRuntimeClasspath
+jakarta.servlet:jakarta.servlet-api:6.1.0=cliClasspath,testRuntimeClasspath
+jakarta.validation:jakarta.validation-api:3.1.0=cliClasspath,testRuntimeClasspath
+jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=cliClasspath,testRuntimeClasspath
+jakarta.xml.bind:jakarta.xml.bind-api:4.0.2=cliClasspath,testRuntimeClasspath
 javax.inject:javax.inject:1=annotationProcessor,errorprone,testAnnotationProcessor
 junit:junit:4.13.2=testCompileClasspath,testRuntimeClasspath
-org.antlr:antlr4-runtime:4.13.2=testRuntimeClasspath
+org.antlr:antlr4-runtime:4.13.2=cliClasspath,testRuntimeClasspath
 org.apache.calcite.avatica:avatica-core:1.29.0=testRuntimeClasspath
 org.apache.calcite.avatica:avatica-metrics:1.29.0=testRuntimeClasspath
 org.apache.calcite:calcite-core:1.42.0=testRuntimeClasspath
 org.apache.calcite:calcite-linq4j:1.42.0=testRuntimeClasspath
-org.apache.commons:commons-exec:1.6.0=testRuntimeClasspath
-org.apache.commons:commons-lang3:3.20.0=testRuntimeClasspath
-org.apache.commons:commons-math3:3.6.1=testRuntimeClasspath
+org.apache.commons:commons-exec:1.6.0=cliClasspath,testRuntimeClasspath
+org.apache.commons:commons-lang3:3.20.0=cliClasspath,testRuntimeClasspath
+org.apache.commons:commons-math3:3.6.1=cliClasspath,testRuntimeClasspath
 org.apache.commons:commons-text:1.15.0=testRuntimeClasspath
-org.apache.curator:curator-client:5.9.0=testCompileClasspath,testRuntimeClasspath
-org.apache.curator:curator-framework:5.9.0=testCompileClasspath,testRuntimeClasspath
+org.apache.curator:curator-client:5.9.0=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.apache.curator:curator-framework:5.9.0=cliClasspath,testCompileClasspath,testRuntimeClasspath
 org.apache.curator:curator-test:5.9.0=testRuntimeClasspath
 org.apache.httpcomponents.client5:httpclient5:5.5=testRuntimeClasspath
 org.apache.httpcomponents.core5:httpcore5-h2:5.3.4=testRuntimeClasspath
 org.apache.httpcomponents.core5:httpcore5:5.3.5=testRuntimeClasspath
-org.apache.logging.log4j:log4j-api:2.26.1=testRuntimeClasspath
-org.apache.logging.log4j:log4j-core:2.26.1=testRuntimeClasspath
-org.apache.logging.log4j:log4j-slf4j2-impl:2.26.1=testRuntimeClasspath
-org.apache.lucene:lucene-analysis-common:10.4.0=localJavadocs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-analysis-kuromoji:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-analysis-nori:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-analysis-phonetic:10.4.0=testRuntimeClasspath
+org.apache.logging.log4j:log4j-api:2.26.1=cliClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-core:2.26.1=cliClasspath,testRuntimeClasspath
+org.apache.logging.log4j:log4j-slf4j2-impl:2.26.1=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-common:10.4.0=cliClasspath,localJavadocs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-kuromoji:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-nori:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-analysis-phonetic:10.4.0=cliClasspath,testRuntimeClasspath
 org.apache.lucene:lucene-analysis-stempel:10.4.0=localJavadocs
-org.apache.lucene:lucene-backward-codecs:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-classification:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-codecs:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-core:10.4.0=localJavadocs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-expressions:10.4.0=localJavadocs,testRuntimeClasspath
-org.apache.lucene:lucene-facet:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-grouping:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-highlighter:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-join:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-memory:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-misc:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-queries:10.4.0=localJavadocs,testCompileClasspath,testRuntimeClasspath
-org.apache.lucene:lucene-queryparser:10.4.0=localJavadocs,testRuntimeClasspath
-org.apache.lucene:lucene-sandbox:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-spatial-extras:10.4.0=localJavadocs,testRuntimeClasspath
-org.apache.lucene:lucene-spatial3d:10.4.0=testRuntimeClasspath
-org.apache.lucene:lucene-suggest:10.4.0=testRuntimeClasspath
+org.apache.lucene:lucene-backward-codecs:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-classification:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-codecs:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-core:10.4.0=cliClasspath,localJavadocs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-expressions:10.4.0=cliClasspath,localJavadocs,testRuntimeClasspath
+org.apache.lucene:lucene-facet:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-grouping:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-highlighter:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-join:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-memory:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-misc:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-queries:10.4.0=cliClasspath,localJavadocs,testCompileClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-queryparser:10.4.0=cliClasspath,localJavadocs,testRuntimeClasspath
+org.apache.lucene:lucene-sandbox:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-spatial-extras:10.4.0=cliClasspath,localJavadocs,testRuntimeClasspath
+org.apache.lucene:lucene-spatial3d:10.4.0=cliClasspath,testRuntimeClasspath
+org.apache.lucene:lucene-suggest:10.4.0=cliClasspath,testRuntimeClasspath
 org.apache.lucene:lucene-test-framework:10.4.0=testCompileClasspath,testRuntimeClasspath
-org.apache.zookeeper:zookeeper-jute:3.9.5=testCompileClasspath,testRuntimeClasspath
-org.apache.zookeeper:zookeeper:3.9.5=testCompileClasspath,testRuntimeClasspath
+org.apache.zookeeper:zookeeper-jute:3.9.5=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.apache.zookeeper:zookeeper:3.9.5=cliClasspath,testCompileClasspath,testRuntimeClasspath
 org.apiguardian:apiguardian-api:1.1.2=testRuntimeClasspath
 org.checkerframework:checker-qual:4.2.2=testRuntimeClasspath
 org.codehaus.janino:commons-compiler:3.1.12=testRuntimeClasspath
 org.codehaus.janino:janino:3.1.12=testRuntimeClasspath
-org.codehaus.woodstox:stax2-api:4.3.1=testRuntimeClasspath
+org.codehaus.woodstox:stax2-api:4.3.1=cliClasspath,testRuntimeClasspath
 org.eclipse.jetty.ee10:jetty-ee10-servlet:12.0.34=testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-client-transport:12.0.34=testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-client:12.0.34=testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-common:12.0.34=testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty.http2:jetty-http2-hpack:12.0.34=testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-client-transport:12.0.34=cliClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-client:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-common:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty.http2:jetty-http2-hpack:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
 org.eclipse.jetty.http2:jetty-http2-server:12.0.34=testRuntimeClasspath
-org.eclipse.jetty:jetty-alpn-client:12.0.34=testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-alpn-java-client:12.0.34=testRuntimeClasspath
+org.eclipse.jetty:jetty-alpn-client:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-alpn-java-client:12.0.34=cliClasspath,testRuntimeClasspath
 org.eclipse.jetty:jetty-alpn-java-server:12.0.34=testRuntimeClasspath
 org.eclipse.jetty:jetty-alpn-server:12.0.34=testRuntimeClasspath
-org.eclipse.jetty:jetty-client:12.0.34=testRuntimeClasspath
-org.eclipse.jetty:jetty-http:12.0.34=testCompileClasspath,testRuntimeClasspath
-org.eclipse.jetty:jetty-io:12.0.34=testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-client:12.0.34=cliClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-http:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-io:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
 org.eclipse.jetty:jetty-rewrite:12.0.34=testRuntimeClasspath
-org.eclipse.jetty:jetty-security:12.0.34=testRuntimeClasspath
-org.eclipse.jetty:jetty-server:12.0.34=testRuntimeClasspath
+org.eclipse.jetty:jetty-security:12.0.34=cliClasspath,testRuntimeClasspath
+org.eclipse.jetty:jetty-server:12.0.34=cliClasspath,testRuntimeClasspath
 org.eclipse.jetty:jetty-session:12.0.34=testRuntimeClasspath
-org.eclipse.jetty:jetty-util:12.0.34=testCompileClasspath,testRuntimeClasspath
-org.glassfish.hk2.external:aopalliance-repackaged:4.0.1=testRuntimeClasspath
-org.glassfish.hk2:hk2-api:4.0.1=testRuntimeClasspath
-org.glassfish.hk2:hk2-locator:4.0.1=testRuntimeClasspath
-org.glassfish.hk2:hk2-utils:4.0.1=testRuntimeClasspath
-org.glassfish.hk2:osgi-resource-locator:3.0.0=testRuntimeClasspath
-org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=testRuntimeClasspath
-org.glassfish.jersey.core:jersey-client:4.0.2=testRuntimeClasspath
-org.glassfish.jersey.core:jersey-common:4.0.2=testRuntimeClasspath
-org.glassfish.jersey.core:jersey-server:4.0.2=testRuntimeClasspath
-org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=testRuntimeClasspath
-org.glassfish.jersey.inject:jersey-hk2:4.0.2=testRuntimeClasspath
-org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=testRuntimeClasspath
-org.glassfish.jersey:jersey-bom:4.0.2=testRuntimeClasspath
+org.eclipse.jetty:jetty-util:12.0.34=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.glassfish.hk2.external:aopalliance-repackaged:4.0.1=cliClasspath,testRuntimeClasspath
+org.glassfish.hk2:hk2-api:4.0.1=cliClasspath,testRuntimeClasspath
+org.glassfish.hk2:hk2-locator:4.0.1=cliClasspath,testRuntimeClasspath
+org.glassfish.hk2:hk2-utils:4.0.1=cliClasspath,testRuntimeClasspath
+org.glassfish.hk2:osgi-resource-locator:3.0.0=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.containers:jersey-container-jetty-http:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.core:jersey-client:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.core:jersey-common:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.core:jersey-server:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.ext:jersey-entity-filtering:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.inject:jersey-hk2:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey.media:jersey-media-json-jackson:4.0.2=cliClasspath,testRuntimeClasspath
+org.glassfish.jersey:jersey-bom:4.0.2=cliClasspath,testRuntimeClasspath
 org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
-org.javassist:javassist:3.30.2-GA=testRuntimeClasspath
+org.javassist:javassist:3.30.2-GA=cliClasspath,testRuntimeClasspath
 org.jooq:joou-java-6:0.9.5=testRuntimeClasspath
-org.jspecify:jspecify:1.0.1=annotationProcessor,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+org.jspecify:jspecify:1.0.1=annotationProcessor,cliClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
 org.junit.jupiter:junit-jupiter-api:5.6.2=testRuntimeClasspath
 org.junit.platform:junit-platform-commons:1.6.2=testRuntimeClasspath
 org.junit:junit-bom:5.6.2=testRuntimeClasspath
 org.locationtech.jts.io:jts-io-common:1.19.0=testRuntimeClasspath
 org.locationtech.jts:jts-core:1.19.0=testRuntimeClasspath
 org.locationtech.proj4j:proj4j:1.2.2=testRuntimeClasspath
-org.locationtech.spatial4j:spatial4j:0.8=testRuntimeClasspath
+org.locationtech.spatial4j:spatial4j:0.8=cliClasspath,testRuntimeClasspath
 org.opentest4j:opentest4j:1.2.0=testRuntimeClasspath
-org.ow2.asm:asm-commons:9.10.1=testRuntimeClasspath
-org.ow2.asm:asm-tree:9.10.1=testRuntimeClasspath
-org.ow2.asm:asm:9.10.1=testRuntimeClasspath
+org.ow2.asm:asm-commons:9.10.1=cliClasspath,testRuntimeClasspath
+org.ow2.asm:asm-tree:9.10.1=cliClasspath,testRuntimeClasspath
+org.ow2.asm:asm:9.10.1=cliClasspath,testRuntimeClasspath
 org.pcollections:pcollections:4.0.1=annotationProcessor,errorprone,testAnnotationProcessor
-org.semver4j:semver4j:6.0.0=testRuntimeClasspath
-org.slf4j:jcl-over-slf4j:2.0.20=testRuntimeClasspath
-org.slf4j:slf4j-api:2.0.20=testCompileClasspath,testRuntimeClasspath
-org.xerial.snappy:snappy-java:1.1.10.8=testRuntimeClasspath
+org.semver4j:semver4j:6.0.0=cliClasspath,testRuntimeClasspath
+org.slf4j:jcl-over-slf4j:2.0.20=cliClasspath,testRuntimeClasspath
+org.slf4j:slf4j-api:2.0.20=cliClasspath,testCompileClasspath,testRuntimeClasspath
+org.xerial.snappy:snappy-java:1.1.10.8=cliClasspath,testRuntimeClasspath
 empty=compileClasspath,jarValidation,localPlaybook,missingdoclet,officialPlaybook,refGuide,runtimeClasspath
diff --git a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
index 3049ad1cd5e..309b2bd959f 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
@@ -18,6 +18,25 @@
 .Deployment Guide
 
 * xref:solr-control-script-reference.adoc[]
+** xref:cli/index.adoc[Experimental picocli CLI]
+// CLI-DOCS-START
+*** xref:cli/solr-auth.adoc[auth]
+*** xref:cli/solr-create.adoc[create]
+*** xref:cli/solr-delete.adoc[delete]
+*** xref:cli/solr-start.adoc[start]
+*** xref:cli/solr-status.adoc[status]
+*** xref:cli/solr-stop.adoc[stop]
+*** xref:cli/solr-version.adoc[version]
+*** xref:cli/solr-zk.adoc[zk]
+**** xref:cli/solr-zk-cp.adoc[cp]
+**** xref:cli/solr-zk-downconfig.adoc[downconfig]
+**** xref:cli/solr-zk-ls.adoc[ls]
+**** xref:cli/solr-zk-mkroot.adoc[mkroot]
+**** xref:cli/solr-zk-mv.adoc[mv]
+**** xref:cli/solr-zk-rm.adoc[rm]
+**** xref:cli/solr-zk-upconfig.adoc[upconfig]
+**** xref:cli/solr-zk-updateacls.adoc[updateacls]
+// CLI-DOCS-END
 
 * Installation & Deployment
 ** xref:system-requirements.adoc[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc
new file mode 100644
index 00000000000..f0010959806
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/index.adoc
@@ -0,0 +1,96 @@
+= Experimental picocli Command Line Interface
+:page-toclevels: 2
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+Solr is migrating the `bin/solr` command line tool from Apache Commons CLI to https://picocli.info/[picocli].
+While that work is in progress, both implementations ship together and the picocli one can be enabled on request.
+
+[WARNING]
+====
+The picocli command line interface is *experimental* and disabled by default.
+
+* Only some commands have been migrated so far.
+* Options, defaults and error messages may change without notice between releases.
+* Where this implementation and the default one differ, xref:solr-control-script-reference.adoc[] describes what `bin/solr` actually does by default.
+
+Do not depend on it in scripts or automation yet.
+====
+
+== Enabling It
+
+Set `SOLR_PICOCLI` in your `solr.in.sh` (or `solr.in.cmd` on Windows):
+
+[source,bash]
+----
+SOLR_PICOCLI=true
+----
+
+Or set the equivalent system property for a single invocation:
+
+[source,bash]
+----
+SOLR_TOOL_OPTS="-Dsolr.picocli=true" bin/solr status
+----
+
+Unset it, or set it to anything other than `true`, to return to the default implementation.
+
+== Migrated Commands
+
+The commands below have been migrated and have a reference page generated from their source annotations.
+Any command not listed here is unavailable when the picocli implementation is enabled, and reports an unmatched argument.
+
+// CLI-DOCS-TABLE-START
+[cols="1",options="header"]
+|===
+|Command
+
+|xref:cli/solr-auth.adoc[bin/solr auth]
+|xref:cli/solr-create.adoc[bin/solr create]
+|xref:cli/solr-delete.adoc[bin/solr delete]
+|xref:cli/solr-start.adoc[bin/solr start]
+|xref:cli/solr-status.adoc[bin/solr status]
+|xref:cli/solr-stop.adoc[bin/solr stop]
+|xref:cli/solr-version.adoc[bin/solr version]
+|xref:cli/solr-zk-cp.adoc[bin/solr zk cp]
+|xref:cli/solr-zk-downconfig.adoc[bin/solr zk downconfig]
+|xref:cli/solr-zk-ls.adoc[bin/solr zk ls]
+|xref:cli/solr-zk-mkroot.adoc[bin/solr zk mkroot]
+|xref:cli/solr-zk-mv.adoc[bin/solr zk mv]
+|xref:cli/solr-zk-rm.adoc[bin/solr zk rm]
+|xref:cli/solr-zk-upconfig.adoc[bin/solr zk upconfig]
+|xref:cli/solr-zk-updateacls.adoc[bin/solr zk updateacls]
+|xref:cli/solr-zk.adoc[bin/solr zk]
+|===
+// CLI-DOCS-TABLE-END
+
+== Known Limitations
+
+In addition to the commands that have not been migrated yet:
+
+* On a sub-command, `-v` means `--verbose`; only the top-level `bin/solr` accepts `-v` as a request for the version.
+* `--fail-on-exists` on `bin/solr zk mkroot` treats the option as set whenever it is present, whatever value follows it.
+  This matches the default implementation, but means `--fail-on-exists false` does not disable the check.
+* Error messages for invalid or mutually exclusive options are worded differently from the default implementation.
+* Usage and help output is laid out differently throughout, so anything parsing the output of `--help` will need adjusting.
+* Typing a `zk` sub-command without the `zk` prefix, for example `bin/solr ls`, reports an unmatched argument rather than the reminder the default implementation prints.
+  That syntax was removed in Solr 9; use `bin/solr zk ls`.
+
+== Reporting Problems
+
+Please report anything that behaves differently from xref:solr-control-script-reference.adoc[] against
+https://issues.apache.org/jira/browse/SOLR-17697[SOLR-17697].
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-auth.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-auth.adoc
new file mode 100644
index 00000000000..31a64352cf5
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-auth.adoc
@@ -0,0 +1,124 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr auth
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr auth [-hv] --auth-conf-dir=DIR [--block-unknown=true|false]
+              [--prompt=true|false] [-s=<solrConnection>] --solr-include-file=FILE
+              [--solr-url=<solrUrl>] [--type=TYPE] [-u=<credentials>]
+              [--update-include-file-only=true|false] [-z=<zkHost>] enable|disable
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Updates or enables/disables authentication. Must be run on the Solr server itself.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*--auth-conf-dir*=_DIR_::
+  *(required)* This is where any authentication related configuration files, if any, would be placed. Defaults to $SOLR_HOME.
+
+*--block-unknown*=_true|false_::
+  Blocks all access for unknown users (requires authentication for all endpoints).
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*--prompt*=_true|false_::
+  Prompts the user to provide the credentials. Use either --credentials or --prompt, not both.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-include-file*=_FILE_::
+  *(required)* The Solr include file which contains overridable environment variables for configuring Solr configurations. Defaults to solr.in.sh or solr.in.cmd.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*--type*=_TYPE_::
+  The authentication mechanism to enable (currently only basicAuth). Defaults to 'basicAuth'.
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*--update-include-file-only*=_true|false_::
+  Only update the solr.in.sh or solr.in.cmd file, and skip actual enabling/disabling authentication (i.e. don't update security.json).
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_enable|disable_::
+  Whether to enable or disable authentication.
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-exit-status[]
+== Exit Codes
+*0*::
+  Authentication was enabled or disabled successfully.
+
+*1*::
+  Failed to enable or disable authentication; check output for details.
+
+// end::picocli-generated-man-section-exit-status[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Enable basic authentication with the given credentials
+  bin/solr auth enable --type basicAuth --credentials solr:SolrRocks
+
+[%hardbreaks]
+  # Enable basic authentication, prompting for credentials
+  bin/solr auth enable --type basicAuth --prompt true
+
+[%hardbreaks]
+  # Disable authentication
+  bin/solr auth disable
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc
new file mode 100644
index 00000000000..a359ae8fd2f
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-create.adoc
@@ -0,0 +1,112 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr create
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr create [-hv] -c=<name> [-d=<confDir>] [-n=<confName>]
+                [-rf=<replicationFactor>] [-sh=<shards>] [-u=<credentials>]
+                [-s=<solrConnection> | --solr-url=<solrUrl> | -z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Creates a core or collection depending on whether Solr is running in standalone (core) or SolrCloud mode (collection).
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-c*, *--name*=_<name>_::
+  *(required)* Name of collection or core to create.
+
+*-d*, *--conf-dir*=_<confDir>_::
+  Configuration directory to copy when creating the new collection; default is _default.
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-n*, *--conf-name*=_<confName>_::
+  Configuration name; default is the collection name.
+
+*-rf, --replication-factor*=_<replicationFactor>_::
+  Number of copies of each document across the collection (replicas per shard); default is 1.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*-sh, --shards*=_<shards>_::
+  Number of shards; default is 1.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if that's not known.
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-exit-status[]
+== Exit Codes
+*0*::
+  Collection or core created successfully.
+
+*1*::
+  Failed to create; Solr may not be running, or the collection/core already exists.
+
+// end::picocli-generated-man-section-exit-status[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Create a collection or core
+  bin/solr create -c myCollection
+
+[%hardbreaks]
+  # Create a collection with 2 shards and 2 replicas
+  bin/solr create -c myCollection -s 2 -rf 2
+
+[%hardbreaks]
+  # Create a collection using an existing configset
+  bin/solr create -c myCollection -d myconfig
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-delete.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-delete.adoc
new file mode 100644
index 00000000000..8ee0fa65e14
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-delete.adoc
@@ -0,0 +1,101 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr delete
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr delete [-fhv] [--delete-config] -c=<name> [-u=<credentials>]
+                [-s=<solrConnection> | --solr-url=<solrUrl> | -z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Deletes a collection or core depending on whether Solr is running in SolrCloud or standalone mode.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-c*, *--name*=_<name>_::
+  *(required)* Name of the core / collection to delete.
+
+*--delete-config*::
+  Flag to indicate if the underlying configuration directory for a collection should also be deleted; default is false.
+
+*-f*, *--force*::
+  Deprecated: no longer has any effect; configset deletion is always safely skipped if the configset is still in use by another collection.
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if that's not known.
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-exit-status[]
+== Exit Codes
+*0*::
+  Collection or core deleted successfully.
+
+*1*::
+  Failed to delete; collection or core may not exist, or Solr may not be running.
+
+// end::picocli-generated-man-section-exit-status[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Delete a collection in SolrCloud mode
+  bin/solr delete -c myCollection
+
+[%hardbreaks]
+  # Delete and also remove the associated configset
+  bin/solr delete -c myCollection --delete-config
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc
new file mode 100644
index 00000000000..28970862336
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-start.adoc
@@ -0,0 +1,128 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr start
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr start [-fhqy] [--force] [--user-managed] [--verbose]
+               [--data-home=<dataHome>] [-e=<example>] [--example-dir=<exampleDir>]
+               [--host=<host>] [-j=<jettyParams>] [--jvm-opts=<jvmOpts>]
+               [-m=<memory>] [-p=<port>] [--prompt-inputs=<values>]
+               [--server-dir=<serverDir>] [--solr-home=<solrHome>] [-z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Starts Solr
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*--data-home*=_<dataHome>_::
+  Set solr.data.home system property for index data storage; default is solr.solr.home
+
+*-e*, *--example*=_<example>_::
+  Run an example: cloud, techproducts, schemaless, films
+
+*--example-dir*=_<exampleDir>_::
+  Override the directory containing example configurations used when running examples with --example
+
+*-f*, *--foreground*::
+  Start Solr in foreground; default is background with logs to solr-PORT-console.log
+
+*--force*::
+  Override warning when attempting to start Solr as root user
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*--host*=_<host>_::
+  Specify the hostname this Solr node will advertise to ZooKeeper and other cluster members (SOLR_HOST_ADVERTISE). Defaults to the canonical hostname of the local host.
+
+*-j*, *--jettyconfig*=_<jettyParams>_::
+  Additional Jetty parameters, e.g., -j "--include-jetty-dir=/etc/jetty/custom/server/"
+
+*--jvm-opts*=_<jvmOpts>_::
+  Additional JVM parameters, e.g., --jvm-opts "-verbose:gc"
+
+*-m*, *--memory*=_<memory>_::
+  Set JVM heap size, e.g., -m 4g sets -Xms4g -Xmx4g; default is 512m
+
+*-p*, *--port*=_<port>_::
+  Specify the Solr HTTP port; default is 8983. STOP_PORT=($SOLR_PORT-1000), RMI_PORT=($SOLR_PORT+10000)
+
+*--prompt-inputs*=_<values>_::
+  Don't prompt for input; comma-delimited list of inputs to use when running examples that accept user input
+
+*-q*, *--quiet*::
+  Set log level to WARN (quiet); default is INFO
+
+*--server-dir*=_<serverDir>_::
+  Specify the Solr server directory; default is 'server'
+
+*--solr-home*=_<solrHome>_::
+  Set solr.solr.home system property; default is 'server/solr'. Ignored in examples mode
+
+*--user-managed*::
+  Start Solr in standalone mode. Default is SolrCloud (ZooKeeper) mode.
+
+*--verbose*::
+  Set log level to DEBUG (verbose); default is INFO
+
+*-y*, *--no-prompt*::
+  Don't prompt for input; accept all defaults when running examples
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; default is to start an embedded ZooKeeper on PORT+10000
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Start Solr in SolrCloud mode on the default port
+  bin/solr start
+
+[%hardbreaks]
+  # Start on a custom port with 2g heap
+  bin/solr start -p 8984 -m 2g
+
+[%hardbreaks]
+  # Start in user managed mode
+  bin/solr start --user-managed
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-status.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-status.adoc
new file mode 100644
index 00000000000..506af27a78a
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-status.adoc
@@ -0,0 +1,106 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr status
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr status [-hv] [--short] [--max-wait-secs=<maxWaitSecs>] [-u=<credentials>]
+                [-p=<port> | -s=<solrConnection> | --solr-url=<solrUrl> |
+                -z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Get the status of a Solr server.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*--max-wait-secs*=_<maxWaitSecs>_::
+  Wait up to the specified number of seconds to see Solr running.
+
+*-p*, *--port*=_<port>_::
+  Port on localhost to check status for
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--short*::
+  Short format. Prints one URL per line for running instances
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if that's not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-exit-status[]
+== Exit Codes
+*0*::
+  Solr is running.
+
+*1*::
+  Solr is not running, or the URL/port could not be reached.
+
+// end::picocli-generated-man-section-exit-status[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Check status of Solr running on the default port
+  bin/solr status
+
+[%hardbreaks]
+  # Check status of a specific Solr instance by URL
+  bin/solr status -s http://localhost:8983
+
+[%hardbreaks]
+  # Wait up to 30 seconds for Solr to start
+  bin/solr status --max-wait-secs 30 -s http://localhost:8983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-stop.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-stop.adoc
new file mode 100644
index 00000000000..f50fda6aebe
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-stop.adoc
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr stop
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr stop [-h] [--all] [--verbose] [-k=<key>] [-p=<port>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Stops Solr.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*--all*::
+  Find and stop all running Solr servers on this host
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-k*, *--key*=_<key>_::
+  Stop key; default is solrrocks
+
+*-p*, *--port*=_<port>_::
+  Specify the port the Solr HTTP listener is bound to.
++
+The STOP_PORT is derived as ($SOLR_PORT-1000).
+
+*--verbose*::
+  Enable verbose mode.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Stop Solr running on the default port
+  bin/solr stop -p 8983
+
+[%hardbreaks]
+  # Stop all running Solr instances on this host
+  bin/solr stop --all
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-version.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-version.adoc
new file mode 100644
index 00000000000..05caaf3eab6
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-version.adoc
@@ -0,0 +1,70 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr version
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr version [-hv] [-u=<credentials>] [-s=<solrConnection> |
+                 --solr-url=<solrUrl> | -z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Prints the Solr version.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if that's not known.
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-cp.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-cp.adoc
new file mode 100644
index 00000000000..49ecf2a0b65
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-cp.adoc
@@ -0,0 +1,109 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk cp
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk cp [-hrv] [-s=<solrConnection>] [--solr-home=DIR]
+               [--solr-url=<solrUrl>] [-u=<credentials>] [-z=<zkHost>] <src> <dst>
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Copy files or folders to/from ZooKeeper or ZooKeeper to ZooKeeper.
+
+<src>, <dest> : [file:][/]path/to/local/file or zk:/path/to/zk/node
+NOTE: <src> and <dest> may both be ZooKeeper resources prefixed by 'zk:'
+When <src> is a zk resource, <dest> may be '.'
+If <dest> ends with '/', then <dest> will be a local folder or parent znode
+and the last element of the <src> path will be appended unless <src> also ends in a slash.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-r*, *--recursive*::
+  Apply the command recursively.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-home*=_DIR_::
+  Required to look up configuration for compressing state.json.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_<src>_::
+  *(required)* Source path: [file:][/]path/to/local/file or zk:/path/to/zk/node.
+
+_<dst>_::
+  *(required)* Destination path: [file:][/]path/to/local/file or zk:/path/to/zk/node.
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Upload a local file to ZooKeeper
+  bin/solr zk cp /local/conf/solrconfig.xml zk:/configs/myconfig/solrconfig.xml -z localhost:9983
+
+[%hardbreaks]
+  # Recursively copy a local directory to ZooKeeper
+  bin/solr zk cp -r file:/local/conf/ zk:/configs/myconfig/ -z localhost:9983
+
+[%hardbreaks]
+  # Copy a ZooKeeper node to the current directory
+  bin/solr zk cp zk:/configs/myconfig/solrconfig.xml . -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-downconfig.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-downconfig.adoc
new file mode 100644
index 00000000000..90710607ed7
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-downconfig.adoc
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk downconfig
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk downconfig [-hv] -d=DIR -n=<confName> [-s=<solrConnection>]
+                       [--solr-url=<solrUrl>] [-u=<credentials>] [-z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Download a configset from ZooKeeper to the local filesystem.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-d*, *--conf-dir*=_DIR_::
+  *(required)* Local directory for configs.
++
+The path to write the downloaded configuration set into. If just a name is supplied, `$SOLR_TIP/server/solr/configsets` will be the parent. An absolute path may be supplied as well.
++
+
++
+In either case, _pre-existing configurations at the destination will be overwritten_!
++
+
++
+**Examples:**
++
+* `-d directory_under_configsets`
++
+* `-d /path/to/configset/destination`
++
+
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-n*, *--conf-name*=_<confName>_::
+  *(required)* Configset name in ZooKeeper.
++
+Name of the configuration set under the "/configs" ZooKeeper node.
++
+
++
+You can see available configuration sets in the Admin UI via the Cloud screens. Choose Cloud → Tree → configs to see them.
++
+
++
+If a pre-existing configuration set is specified, it will be overwritten in ZooKeeper.
++
+
++
+**Example:** `-n myconfig`
++
+
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Download a configset from ZooKeeper
+  bin/solr zk downconfig -n myconfig -d /local/conf -z localhost:9983
+
+[%hardbreaks]
+  # Download into a path relative to the Solr installation
+  bin/solr zk downconfig -n myconfig -d server/solr/configsets/myconfig/conf -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-ls.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-ls.adoc
new file mode 100644
index 00000000000..7c917b40154
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-ls.adoc
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk ls
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk ls [-hrv] [-s=<solrConnection>] [--solr-url=<solrUrl>]
+               [-u=<credentials>] [-z=<zkHost>] <path>
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+List the contents of a ZooKeeper node.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-r*, *--recursive*::
+  Apply the command recursively.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_<path>_::
+  *(required)* The path of the ZooKeeper znode path to list.
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # List top-level ZooKeeper nodes
+  bin/solr zk ls /configs -z localhost:9983
+
+[%hardbreaks]
+  # Recursively list a configset
+  bin/solr zk ls -r /configs/myconfig -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mkroot.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mkroot.adoc
new file mode 100644
index 00000000000..6a72e934ded
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mkroot.adoc
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk mkroot
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk mkroot [-hv] [--fail-on-exists[=<arg>]] [-s=<solrConnection>]
+                   [--solr-url=<solrUrl>] [-u=<credentials>] [-z=<zkHost>] <path>
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Make a znode in ZooKeeper with no data. Can be used to make a path of arbitrary depth but primarily intended to create a 'chroot'.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*--fail-on-exists*[=_<arg>_]::
+  Raise an error if the znode already exists. Defaults to false.
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_<path>_::
+  *(required)* The ZooKeeper znode path to create.
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Create a chroot path for Solr
+  bin/solr zk mkroot /solr -z localhost:2181
+
+[%hardbreaks]
+  # Create a nested chroot path
+  bin/solr zk mkroot /solr/myapp -z localhost:2181
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mv.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mv.adoc
new file mode 100644
index 00000000000..1de113d0973
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-mv.adoc
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk mv
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk mv [-hv] [-s=<solrConnection>] [--solr-url=<solrUrl>]
+               [-u=<credentials>] [-z=<zkHost>] <src> <dst>
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Move (rename) a znode on ZooKeeper.
+
+<src>, <dest> : ZooKeeper nodes, the 'zk:' prefix is optional.
+If <dest> ends with '/', then <dest> will be a parent znode
+and the last element of the <src> path will be appended.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_<src>_::
+  *(required)* Source ZooKeeper znode path (zk: prefix optional).
+
+_<dst>_::
+  *(required)* Destination ZooKeeper znode path (zk: prefix optional).
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Rename a configset
+  bin/solr zk mv /configs/oldconfig /configs/myconfig -z localhost:9983
+
+[%hardbreaks]
+  # Move a node into a parent znode
+  bin/solr zk mv /configs/myconfig /configs/ -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-rm.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-rm.adoc
new file mode 100644
index 00000000000..e381d4de645
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-rm.adoc
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk rm
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk rm [-hrv] [-s=<solrConnection>] [--solr-url=<solrUrl>]
+               [-u=<credentials>] [-z=<zkHost>] <path>
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Remove a znode from ZooKeeper.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-r*, *--recursive*::
+  Apply the command recursively.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_<path>_::
+  *(required)* The ZooKeeper znode path to remove (zk: prefix optional).
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Remove a single file from ZooKeeper
+  bin/solr zk rm /configs/myconfig/solrconfig.xml -z localhost:9983
+
+[%hardbreaks]
+  # Recursively remove a configset
+  bin/solr zk rm -r /configs/myconfig -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-upconfig.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-upconfig.adoc
new file mode 100644
index 00000000000..f42bc40c1c7
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-upconfig.adoc
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk upconfig
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk upconfig [-hv] -d=DIR -n=<confName> [-s=<solrConnection>]
+                     [--solr-url=<solrUrl>] [-u=<credentials>] [-z=<zkHost>]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Upload a configset from the local filesystem to ZooKeeper.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-d*, *--conf-dir*=_DIR_::
+  *(required)* Local directory for configs.
++
+The local directory of the configuration set to upload. It should have a `conf` directory immediately below it that in turn contains `solrconfig.xml` etc.
++
+
++
+If just a name is supplied, `$SOLR_TIP/server/solr/configsets` will be checked for this name. An absolute path may be supplied instead.
++
+
++
+**Examples:**
++
+* `-d directory_under_configsets`
++
+* `-d /path/to/configset/source`
++
+
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-n*, *--conf-name*=_<confName>_::
+  *(required)* Configset name in ZooKeeper.
++
+Name of the configuration set under the "/configs" ZooKeeper node.
++
+
++
+You can see available configuration sets in the Admin UI via the Cloud screens. Choose Cloud → Tree → configs to see them.
++
+
++
+If a pre-existing configuration set is specified, it will be overwritten in ZooKeeper.
++
+
++
+**Example:** `-n myconfig`
++
+
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Upload a configset to ZooKeeper
+  bin/solr zk upconfig -n myconfig -d /local/conf -z localhost:9983
+
+[%hardbreaks]
+  # Upload using a path relative to the Solr installation
+  bin/solr zk upconfig -n myconfig -d server/solr/configsets/myconfig/conf -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-updateacls.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-updateacls.adoc
new file mode 100644
index 00000000000..e8e80cb65bf
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk-updateacls.adoc
@@ -0,0 +1,90 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk updateacls
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk updateacls [-hv] [-s=<solrConnection>] [--solr-url=<solrUrl>]
+                       [-u=<credentials>] [-z=<zkHost>] <path>
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Recursively re-applies ZooKeeper ACLs to a znode and all its descendants. The ACLs are determined by the ZkACLProvider configured via the zkACLProvider system property (pass via SOLR_TOOL_OPTS). Without a custom provider, the default OPEN_ACL_UNSAFE ACLs are applied.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+*-s*, *--solr-connection*=_<solrConnection>_::
+  Zookeeper or HTTP(s) connection string; unnecessary if SOLR_CONNECTION is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+*--solr-url*=_<solrUrl>_::
+  Base Solr URL, which can be used to determine the zk-host if --zk-host is not known
+
+*-u*, *--credentials*=_<credentials>_::
+  Credentials in the format username:password. Example: --credentials solr:SolrRocks
+
+*-v*, *--verbose*::
+  Enable verbose mode.
+
+*-z*, *--zk-host*=_<zkHost>_::
+  Zookeeper connection string; unnecessary if ZK_HOST is defined in solr.in.sh; otherwise, defaults to localhost:9983.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-arguments[]
+== Arguments
+
+_<path>_::
+  *(required)* The ZooKeeper znode path to update ACLs for.
+
+// end::picocli-generated-man-section-arguments[]
+
+// tag::picocli-generated-man-section-footer[]
+== Examples
+[%hardbreaks]
+  # Apply ACLs to the root Solr znode
+  bin/solr zk updateacls /solr -z localhost:9983
+
+[%hardbreaks]
+  # Apply ACLs to a specific configset
+  bin/solr zk updateacls /configs/myconfig -z localhost:9983
+
+// end::picocli-generated-man-section-footer[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk.adoc
new file mode 100644
index 00000000000..991b4838142
--- /dev/null
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/cli/solr-zk.adoc
@@ -0,0 +1,83 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+// DO NOT EDIT -- this page is auto-generated from picocli annotations.
+// To update: modify the @Command/@Option annotations in the Java source, then run:
+//   ./gradlew :solr:solr-ref-guide:generateCliDocs
+
+= bin/solr zk
+:page-toclevels: 2
+
+[WARNING]
+====
+This page documents the *experimental* picocli implementation of `bin/solr`, which is not enabled by default.
+For what `bin/solr` does by default, see xref:solr-control-script-reference.adoc[].
+See xref:cli/index.adoc[] to enable this implementation and for its known limitations.
+====
+
+// tag::picocli-generated-man-section-synopsis[]
+== Synopsis
+
+....
+bin/solr zk [-h] [COMMAND]
+....
+
+// end::picocli-generated-man-section-synopsis[]
+
+// tag::picocli-generated-man-section-description[]
+== Description
+
+Sub commands for working with ZooKeeper.
+
+// end::picocli-generated-man-section-description[]
+
+// tag::picocli-generated-man-section-options[]
+== Options
+
+*-h*, *--help*::
+  Print this help message and exit.
+
+// end::picocli-generated-man-section-options[]
+
+// tag::picocli-generated-man-section-commands[]
+== Commands
+
+xref:cli/solr-zk-downconfig.adoc[*downconfig*]::
+  Download a configset from ZooKeeper to the local filesystem.
+
+xref:cli/solr-zk-upconfig.adoc[*upconfig*]::
+  Upload a configset from the local filesystem to ZooKeeper.
+
+xref:cli/solr-zk-cp.adoc[*cp*]::
+  Copy files or folders to/from ZooKeeper or ZooKeeper to ZooKeeper.
+
+xref:cli/solr-zk-ls.adoc[*ls*]::
+  List the contents of a ZooKeeper node.
+
+xref:cli/solr-zk-mkroot.adoc[*mkroot*]::
+  Make a znode in ZooKeeper with no data. Can be used to make a path of arbitrary depth but primarily intended to create a 'chroot'.
+
+xref:cli/solr-zk-mv.adoc[*mv*]::
+  Move (rename) a znode on ZooKeeper.
+
+xref:cli/solr-zk-rm.adoc[*rm*]::
+  Remove a znode from ZooKeeper.
+
+xref:cli/solr-zk-updateacls.adoc[*updateacls*]::
+  Recursively re-applies ZooKeeper ACLs to a znode and all its descendants. The ACLs are determined by the ZkACLProvider configured via the zkACLProvider system property (pass via SOLR_TOOL_OPTS). Without a custom provider, the default OPEN_ACL_UNSAFE ACLs are applied.
+
+// end::picocli-generated-man-section-commands[]
diff --git a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc
index da569f9ef95..16280cd1d17 100644
--- a/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc
+++ b/solr/solr-ref-guide/modules/deployment-guide/pages/solr-control-script-reference.adoc
@@ -25,6 +25,15 @@ The SolrCLI makes Solr easier to work with by providing simple commands and opti
 
 More examples of SolrCLI in use are available throughout this Guide, particularly in the sections xref:installing-solr.adoc#starting-solr[Starting Solr] and xref:getting-started:tutorial-solrcloud.adoc[].
 
+[NOTE]
+====
+This page documents the `bin/solr` script as it behaves by default.
+
+An experimental re-implementation of the command line interface, built on picocli, is also included in this release.
+It is disabled by default, and the options it accepts differ in places from those documented here.
+See xref:cli/index.adoc[] if you would like to try it.
+====
+
 == Starting and Stopping
 
 === Start and Restart
diff --git a/solr/solrj-jetty/gradle.lockfile b/solr/solrj-jetty/gradle.lockfile
index 181ce52560e..e5264a2d9af 100644
--- a/solr/solrj-jetty/gradle.lockfile
+++ b/solr/solrj-jetty/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=jarValidation,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,testRuntimeClasspath
 commons-io:commons-io:2.22.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/solrj-streaming/gradle.lockfile b/solr/solrj-streaming/gradle.lockfile
index c0f892c081b..3002cd24afe 100644
--- a/solr/solrj-streaming/gradle.lockfile
+++ b/solr/solrj-streaming/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=compileClasspath,jarValidation,runtimeClasspath,testCo
 commons-cli:commons-cli:1.11.0=jarValidation,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,testRuntimeClasspath
 commons-io:commons-io:2.22.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/solrj-zookeeper/gradle.lockfile b/solr/solrj-zookeeper/gradle.lockfile
index 29e222099b4..ac41c82bf4d 100644
--- a/solr/solrj-zookeeper/gradle.lockfile
+++ b/solr/solrj-zookeeper/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=jarValidation,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/solrj/gradle.lockfile b/solr/solrj/gradle.lockfile
index 57549693cea..0dfe64c1ad4 100644
--- a/solr/solrj/gradle.lockfile
+++ b/solr/solrj/gradle.lockfile
@@ -34,6 +34,7 @@ com.tdunning:t-digest:3.3=jarValidation,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=jarValidation,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,testRuntimeClasspath
 commons-io:commons-io:2.22.0=jarValidation,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/test-framework/build.gradle b/solr/test-framework/build.gradle
index e7338c37042..aa4114f3c96 100644
--- a/solr/test-framework/build.gradle
+++ b/solr/test-framework/build.gradle
@@ -61,6 +61,8 @@ dependencies {
   implementation libs.apache.log4j.api
   implementation libs.apache.log4j.core
   implementation libs.commonscli.commonscli
+  // Needed on the compile classpath because this project compiles against annotated org.apache.solr.cli types
+  implementation libs.picocli
   implementation libs.opentelemetry.api
   implementation(libs.opentelemetry.exporter.prometheus) {
     transitive = false // most things don't want or already have
diff --git a/solr/test-framework/gradle.lockfile b/solr/test-framework/gradle.lockfile
index 3683f576552..ddc581522c7 100644
--- a/solr/test-framework/gradle.lockfile
+++ b/solr/test-framework/gradle.lockfile
@@ -35,6 +35,7 @@ com.tdunning:t-digest:3.3=jarValidation,runtimeClasspath,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=compileClasspath,jarValidation,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,runtimeClasspath,testRuntimeClasspath
 commons-io:commons-io:2.22.0=compileClasspath,jarValidation,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=compileClasspath,jarValidation,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=compileClasspath,jarValidation,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor
diff --git a/solr/webapp/gradle.lockfile b/solr/webapp/gradle.lockfile
index 0648bef7318..f81bf6e7a68 100644
--- a/solr/webapp/gradle.lockfile
+++ b/solr/webapp/gradle.lockfile
@@ -35,6 +35,7 @@ com.tdunning:t-digest:3.3=jarValidation,solrCore,testRuntimeClasspath
 commons-cli:commons-cli:1.11.0=jarValidation,solrCore,testRuntimeClasspath
 commons-codec:commons-codec:1.22.1=jarValidation,solrCore,testRuntimeClasspath
 commons-io:commons-io:2.22.0=jarValidation,solrCore,testCompileClasspath,testRuntimeClasspath
+info.picocli:picocli:4.7.6=jarValidation,solrCore,testRuntimeClasspath
 io.dropwizard.metrics:metrics-core:4.2.39=jarValidation,solrCore,testRuntimeClasspath
 io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,errorprone,testAnnotationProcessor
 io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,errorprone,testAnnotationProcessor


(solr) 04/12: Ref guide, major-changes-in-solr-9.adoc: 9.11

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit aa7bb22c4b819dd3aa4d95246887a03a0b152003
Author: Jan Høydahl <ja...@apache.org>
AuthorDate: Wed Oct 7 07:18:00 2026 -0400

    Ref guide, major-changes-in-solr-9.adoc: 9.11
    
    (cherry picked from commit ce063d9877c73bd0a23f224ce4af2d3a7943d00e)
---
 .../pages/major-changes-in-solr-9.adoc             | 69 ++++++++++++++++++++++
 1 file changed, 69 insertions(+)

diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
index 45246a1a747..c3187564133 100644
--- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
+++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-9.adoc
@@ -67,6 +67,75 @@ It is always strongly recommended that you fully reindex your documents after a
 In Solr 8, it was possible to add docValues to a schema without re-indexing via `UninvertDocValuesMergePolicy`, an advanced/expert utility.
 Due to changes in Lucene 9, that isn't possible any more.
 
+== Solr 9.11
+
+=== Max distributed requests now configurable
+
+The internal HTTP client used for distributed shard sub-requests previously had a hard-coded limit of 1000 concurrent async requests per node.
+In large clusters, a single query can fan out to hundreds of sub-requests, quickly exhausting this limit and causing requests to queue, potentially leading to stalls or timeouts.
+This limit is now configurable via the system property `solr.solrj.http.jetty.async_requests.max`.
+
+Current permit utilization can be monitored via the `solr_client_request_async_permits` metric (see xref:deployment-guide:metrics-reporting.adoc#http-client-registry[HTTP Client Registry]).
+
+=== Docker
+
+The `gosu` binary is no longer installed in the Solr Docker image. See https://github.com/tianon/gosu[gosu github page] for alternatives, such as `runuser`, `setpriv` or `chroot`.
+
+=== Removing 'local' Tika
+
+The project normally doesn't remove functionality in a minor release, but we made an exception to avoid shipping Tika 1.x with known vulnerabilities.
+
+* LocalTikaExtractionBackend, which was deprecated in 9.10, has been removed. The `tikaserver` extraction backend is now the only supported backend for the ExtractingRequestHandler, and the default. Users must configure a Tika Server URL via the `tikaserver.url` parameter. (SOLR-17961, SOLR-18037)
++
+NOTE: The previous parse-context-based configuration (`parseContext.config`) is no longer supported. Tika parser-specific properties must now be configured directly on the Tika Server itself, rather than through Solr configuration. Please refer to the Tika Server documentation for details on how to set these properties.
+
+=== Luke requests now distributed by default
+
+In SolrCloud mode, the xref:indexing-guide:luke-request-handler.adoc[Luke Request Handler] (`/admin/luke`) now automatically distributes requests across all shards in the collection and returns an aggregated view of the index.
+Previously, Luke only reported on the local shard that received the request, giving an incomplete picture of multi-shard collections.
+In user-managed clusters, you can opt into distributed behavior by passing the `shards` parameter with explicit shard URLs.
+See xref:indexing-guide:luke-request-handler.adoc[] for more details.
+
+To revert to the previous local-only behavior, set `distrib=false` on individual requests, or configure it as a default in `solrconfig.xml`:
+
+[source,xml]
+----
+<requestHandler name="/admin/luke" class="solr.LukeRequestHandler">
+  <lst name="defaults">
+    <bool name="distrib">false</bool>
+  </lst>
+</requestHandler>
+----
+
+=== JWT Authentication
+
+The `blockUnknown` setting in the JWT Authentication plugin now defaults to `true`, meaning requests without a valid JWT token are blocked by default.
+In Solr 9.10 and earlier, the code default was `false` (pass-through), which contradicted the reference guide documentation that described `true` as the default.
+Users upgrading from 9.10 who relied on the pass-through behavior must explicitly set `"blockUnknown": false` in their `security.json`.
+
+=== Basic Authentication
+
+The Basic Authentication password policy has been strengthened: a password may no longer be equal to its username.
+Login attempts where the password equals the username are now rejected, and creating or editing such a user (via the Admin UI, the Authentication API, or `bin/solr auth`) is no longer allowed.
+
+If you are upgrading and still have Basic Auth users whose password equals their username, you can temporarily re-enable the old behavior by setting the system property `-Dsolr.security.auth.basicauth.allowuseraspassword=true` (or the environment variable `SOLR_SECURITY_AUTH_BASICAUTH_ALLOWUSERASPASSWORD=true`).
+When enabled, this escape hatch relaxes both the login-time check and the user creation/editing check, so existing accounts keep working and can still be managed.
+It is intended as a temporary measure while you migrate the affected accounts to stronger passwords, and should be removed once that is done.
+
+=== Connecting to Other Clusters
+
+Streaming expressions and the cross-collection join query parser now restrict which SolrCloud clusters a `zkHost` parameter may refer to:
+
+* A ZooKeeper connection string must match the local cluster's exactly (including any chroot), or be listed in the new xref:configuration-guide:configuring-solr-xml.adoc#allow-zk-hosts[`allowZkHosts`] setting in `solr.xml` (or the `solr.allowZkHosts` system property).
+* The cross-collection join query parser no longer accepts both `zkHost` and `solrUrl`; specify at most one.
+
+If you use these features to query another cluster, add its connection details to `allowZkHosts` before upgrading.
+
+=== /update/extract requires the "update" permission
+
+`/update/extract` (`ExtractingRequestHandler` in the `extraction` module) requires the `update` permission rather than `read`, since it indexes documents.
+Deployments where `read`-only users submit documents through `/update/extract` must grant those users `update` as well.
+
 == Solr 9.10
 
 === SolrJ


(solr) 08/12: SOLR-18124: Trace index replication with a span (#4963)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit b20293b157365b096e96e91ec85e313fb67b5b59
Author: David Smiley <ds...@apache.org>
AuthorDate: Wed Oct 7 14:38:41 2026 -0400

    SOLR-18124: Trace index replication with a span (#4963)
    
    Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
    (cherry picked from commit 6d6504668e3cac77f74d6ad2b2b20754de1c3bec)
---
 .../java/org/apache/solr/handler/IndexFetcher.java |  8 ++++++++
 .../apache/solr/handler/ReplicationHandler.java    | 24 +++++++++++++++++++++-
 2 files changed, 31 insertions(+), 1 deletion(-)

diff --git a/solr/core/src/java/org/apache/solr/handler/IndexFetcher.java b/solr/core/src/java/org/apache/solr/handler/IndexFetcher.java
index b6fa1146959..2652f2add4c 100644
--- a/solr/core/src/java/org/apache/solr/handler/IndexFetcher.java
+++ b/solr/core/src/java/org/apache/solr/handler/IndexFetcher.java
@@ -37,6 +37,7 @@ import static org.apache.solr.handler.admin.api.ReplicationAPIBase.FILE_STREAM;
 import static org.apache.solr.handler.admin.api.ReplicationAPIBase.GENERATION;
 import static org.apache.solr.handler.admin.api.ReplicationAPIBase.OFFSET;
 
+import io.opentelemetry.api.trace.Span;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
@@ -499,6 +500,9 @@ public class IndexFetcher {
 
       log.info("Leader's generation: {}", latestGeneration);
       log.info("Leader's version: {}", latestVersion);
+      Span.current()
+          .setAttribute("replication.leader", leaderCoreUrl)
+          .setAttribute("replication.leaderGeneration", latestGeneration);
 
       // TODO: make sure that getLatestCommit only returns commit points for the main index (i.e. no
       // side-car indexes)
@@ -576,6 +580,7 @@ public class IndexFetcher {
       if (log.isInfoEnabled()) {
         log.info("Number of files in latest index in leader: {}", filesToDownload.size());
       }
+      Span.current().setAttribute("replication.leaderFiles", filesToDownload.size());
 
       // Create the sync service
       fsyncService =
@@ -687,6 +692,9 @@ public class IndexFetcher {
               timeTakenSeconds,
               bytesDownloadedPerSecond,
               tmpIndexDir);
+          Span.current()
+              .setAttribute("replication.fullCopy", isFullCopyNeeded)
+              .setAttribute("replication.bytesDownloaded", bytesDownloaded);
 
           Collection<Map<String, Object>> modifiedConfFiles =
               getModifiedConfFiles(confFilesToDownload);
diff --git a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
index ddd9793932b..6278a33fbfb 100644
--- a/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/ReplicationHandler.java
@@ -33,6 +33,8 @@ import static org.apache.solr.handler.admin.api.ReplicationAPIBase.TLOG_FILE;
 import io.opentelemetry.api.common.Attributes;
 import io.opentelemetry.api.metrics.ObservableDoubleMeasurement;
 import io.opentelemetry.api.metrics.ObservableLongMeasurement;
+import io.opentelemetry.api.trace.Span;
+import io.opentelemetry.api.trace.StatusCode;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
@@ -115,6 +117,7 @@ import org.apache.solr.util.NumberUtils;
 import org.apache.solr.util.RefCounted;
 import org.apache.solr.util.plugin.SolrCoreAware;
 import org.apache.solr.util.stats.MetricUtils;
+import org.apache.solr.util.tracing.TraceUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.MDC;
@@ -497,9 +500,28 @@ public class ReplicationHandler extends RequestHandlerBase
 
   private volatile IndexFetcher currentIndexFetcher;
 
+  public IndexFetchResult doFetch(SolrParams solrParams, boolean forceReplication) {
+    final Span span = TraceUtils.getGlobalTracer().spanBuilder("replication").startSpan();
+    TraceUtils.setDbInstance(span, core.getName());
+    try (var scope = span.makeCurrent()) {
+      assert scope != null; // prevent javac warning about scope being unused
+      IndexFetchResult result = doFetchInternal(solrParams, forceReplication);
+      span.setAttribute("replication.result", result.getMessage());
+      if (!result.getSuccessful()) {
+        span.setStatus(StatusCode.ERROR);
+      }
+      if (result.getException() != null) {
+        span.recordException(result.getException());
+      }
+      return result;
+    } finally {
+      span.end();
+    }
+  }
+
   @SuppressWarnings(
       "ReferenceEquality") // detecting the shared pollingIndexFetcher vs. a one-off, by identity
-  public IndexFetchResult doFetch(SolrParams solrParams, boolean forceReplication) {
+  private IndexFetchResult doFetchInternal(SolrParams solrParams, boolean forceReplication) {
     String leaderUrl = solrParams.get(LEADER_URL, null);
     if (!indexFetchLock.tryLock()) return IndexFetchResult.LOCK_OBTAIN_FAILED;
     if (core.getCoreContainer().isShutDown()) {


(solr) 09/12: SOLR-18504: Merge duplicate OpenAPI path templates (#5024)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit b5f148ad7bb4547b4257053cb8035c621413bf50
Author: Jan Høydahl <ja...@apache.org>
AuthorDate: Wed Oct 7 14:39:27 2026 +0200

    SOLR-18504: Merge duplicate OpenAPI path templates (#5024)
    
    (cherry picked from commit a2f43aadfd212f6d9f05cdcdb5bd27b11fac81b1)
---
 .../SOLR-18504-duplicate-path-templates.yml        | 10 +++++++++
 solr/api/build.gradle                              | 24 ++++++++++++++++++++++
 .../client/api/endpoint/ClusterFileStoreApis.java  |  4 ++--
 .../solr/client/api/endpoint/GetSchemaApi.java     |  5 +++--
 .../solr/handler/admin/api/GetSchemaField.java     | 10 ++++-----
 5 files changed, 44 insertions(+), 9 deletions(-)

diff --git a/changelog/unreleased/SOLR-18504-duplicate-path-templates.yml b/changelog/unreleased/SOLR-18504-duplicate-path-templates.yml
new file mode 100644
index 00000000000..1e93ea2bcd9
--- /dev/null
+++ b/changelog/unreleased/SOLR-18504-duplicate-path-templates.yml
@@ -0,0 +1,10 @@
+title: >
+  The OpenAPI spec no longer describes the same URL twice: the dynamic-field and filestore-file
+  endpoints each named their path parameter differently per HTTP method, so consumers of the spec
+  saw two colliding entries, each missing the other's operations.
+type: fixed
+authors:
+  - name: Jan Høydahl
+links:
+  - name: SOLR-18504
+    url: https://issues.apache.org/jira/browse/SOLR-18504
diff --git a/solr/api/build.gradle b/solr/api/build.gradle
index fb42df27a30..58386b103f2 100644
--- a/solr/api/build.gradle
+++ b/solr/api/build.gradle
@@ -79,6 +79,30 @@ dependencies {
   swaggerBuild libs.swagger3.jaxrs2.jakarta
 }
 
+// OpenAPI treats templated paths that differ only in parameter name as the same path, so
+// such a collision silently hides operations from consumers of the spec.
+tasks.register('validateOpenApiSpec') {
+  dependsOn resolve
+  def specFile = file(project.openApiSpecFile)
+  def marker = layout.buildDirectory.file("openapi-spec-validated.txt").get().asFile
+  inputs.file(specFile)
+  outputs.file(marker)
+  doLast {
+    def collisions = new groovy.json.JsonSlurper().parse(specFile).paths.keySet()
+        .groupBy { it.replaceAll(/\{[^}]*\}/, '{}') }
+        .findAll { it.value.size() > 1 }
+    if (!collisions.isEmpty()) {
+      throw new GradleException(
+          "OpenAPI spec declares paths that differ only in path-parameter name:\n"
+              + collisions.values().flatten().collect { "  " + it }.join("\n") + "\n"
+              + "Make the @PathParam names agree so the operations merge into a single path.")
+    }
+    marker.text = "ok"
+  }
+}
+
+check.dependsOn validateOpenApiSpec
+
 // Non-Java client generation tasks below:
 task buildJSClient(type: org.openapitools.generator.gradle.plugin.tasks.GenerateTask) {
   generatorName.set("javascript")
diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/ClusterFileStoreApis.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/ClusterFileStoreApis.java
index e8dafdd4498..9effa63f791 100644
--- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ClusterFileStoreApis.java
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ClusterFileStoreApis.java
@@ -87,10 +87,10 @@ public interface ClusterFileStoreApis {
   @Operation(
       summary = "Delete a file or directory from the filestore.",
       tags = {"file-store"})
-  @Path("/files{path:.+}")
+  @Path("/files{filePath:.+}")
   SolrJerseyResponse deleteFile(
       @Parameter(description = "Path to a file or directory within the filestore")
-          @PathParam("path")
+          @PathParam("filePath")
           String path,
       @Parameter(
               description =
diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/GetSchemaApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/GetSchemaApi.java
index 119bf9d19ce..a42f832b154 100644
--- a/solr/api/src/java/org/apache/solr/client/api/endpoint/GetSchemaApi.java
+++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/GetSchemaApi.java
@@ -78,12 +78,13 @@ public interface GetSchemaApi {
     SchemaListDynamicFieldsResponse listDynamicFields();
 
     @GET
-    @Path("/dynamicfields/{fieldName}")
+    @Path("/dynamicfields/{dynamicFieldName}")
     @StoreApiParameters
     @Operation(
         summary = "Get detailed info about a single dynamic field",
         tags = {"schema"})
-    SchemaGetDynamicFieldInfoResponse getDynamicFieldInfo(@PathParam("fieldName") String fieldName);
+    SchemaGetDynamicFieldInfoResponse getDynamicFieldInfo(
+        @PathParam("dynamicFieldName") String dynamicFieldName);
 
     @GET
     @Path("/fieldtypes")
diff --git a/solr/core/src/java/org/apache/solr/handler/admin/api/GetSchemaField.java b/solr/core/src/java/org/apache/solr/handler/admin/api/GetSchemaField.java
index 70fc868bee5..e19b08fc325 100644
--- a/solr/core/src/java/org/apache/solr/handler/admin/api/GetSchemaField.java
+++ b/solr/core/src/java/org/apache/solr/handler/admin/api/GetSchemaField.java
@@ -49,7 +49,7 @@ import org.apache.solr.security.PermissionNameProvider;
  *   <li>/fields/{fieldName}
  *   <li>/copyfields
  *   <li>/dynamicfields
- *   <li>/dynamicfields/{fieldName}
+ *   <li>/dynamicfields/{dynamicFieldName}
  *   <li>/fieldtypes
  *   <li>/fieldtypes/{fieldTypeName}
  * </ul>
@@ -122,8 +122,8 @@ public class GetSchemaField extends JerseyResource implements GetSchemaApi.Field
 
   @Override
   @PermissionName(PermissionNameProvider.Name.SCHEMA_READ_PERM)
-  public SchemaGetDynamicFieldInfoResponse getDynamicFieldInfo(String fieldName) {
-    if (fieldName == null) {
+  public SchemaGetDynamicFieldInfoResponse getDynamicFieldInfo(String dynamicFieldName) {
+    if (dynamicFieldName == null) {
       throw new SolrException(
           SolrException.ErrorCode.BAD_REQUEST, "Dynamic field name must not be null");
     }
@@ -132,13 +132,13 @@ public class GetSchemaField extends JerseyResource implements GetSchemaApi.Field
     final String realName = "dynamicfields";
 
     SimpleOrderedMap<Object> dynamicFieldInfo =
-        retrieveFieldInfoOfType(realName, fieldName, params);
+        retrieveFieldInfoOfType(realName, dynamicFieldName, params);
     if (dynamicFieldInfo != null) {
       response.dynamicFieldInfo = dynamicFieldInfo;
       return response;
     }
     throw new SolrException(
-        SolrException.ErrorCode.NOT_FOUND, "No such dynamic field [" + fieldName + "]");
+        SolrException.ErrorCode.NOT_FOUND, "No such dynamic field [" + dynamicFieldName + "]");
   }
 
   @Override


(solr) 03/12: SOLR-18493: Rename Index & Query section in new admin UI (#4994)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 2bea0aef96587da6425433bbc6da6bdd203e94ba
Author: Jane Sandberg <sa...@users.noreply.github.com>
AuthorDate: Wed Oct 7 03:21:43 2026 -0700

    SOLR-18493: Rename Index & Query section in new admin UI (#4994)
    
    (cherry picked from commit 8f926b6532cdc625d11d91a1c99310c8de63e96f)
---
 changelog/unreleased/SOLR-18493-rename-index-query-new-ui.yml      | 7 +++++++
 ui/shared/src/commonMain/composeResources/values/strings.xml       | 2 +-
 .../solr/ui/components/main/integration/DefaultMainComponent.kt    | 4 ++--
 .../kotlin/org/apache/solr/ui/views/navigation/MainMenu.kt         | 2 +-
 .../org/apache/solr/ui/views/navigation/NavigationSideBar.kt       | 6 +++---
 5 files changed, 14 insertions(+), 7 deletions(-)

diff --git a/changelog/unreleased/SOLR-18493-rename-index-query-new-ui.yml b/changelog/unreleased/SOLR-18493-rename-index-query-new-ui.yml
new file mode 100644
index 00000000000..4aa6ca6af89
--- /dev/null
+++ b/changelog/unreleased/SOLR-18493-rename-index-query-new-ui.yml
@@ -0,0 +1,7 @@
+title: Rename Index & Query section in new admin UI
+type: changed
+authors:
+  - name: Jane Sandberg
+links:
+  - name: SOLR-18493
+    url: https://issues.apache.org/jira/browse/SOLR-18493
diff --git a/ui/shared/src/commonMain/composeResources/values/strings.xml b/ui/shared/src/commonMain/composeResources/values/strings.xml
index 57c9927b34e..1b28a6a8189 100644
--- a/ui/shared/src/commonMain/composeResources/values/strings.xml
+++ b/ui/shared/src/commonMain/composeResources/values/strings.xml
@@ -56,7 +56,7 @@
   <string name="nav_environment">Environment</string>
   <string name="nav_logging">Logging</string>
   <string name="nav_metrics">Metrics</string>
-  <string name="nav_queries_and_operations">Queries &amp; Operations</string>
+  <string name="nav_index_and_query">Index &amp; Query</string>
   <string name="nav_security">Security</string>
   <string name="nav_thread_dump">Thread Dump</string>
 
diff --git a/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt b/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt
index 1a354267c43..d3374d0b49a 100644
--- a/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt
+++ b/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/components/main/integration/DefaultMainComponent.kt
@@ -192,7 +192,7 @@ class DefaultMainComponent internal constructor(
         data object Collections : Configuration
 
         @Serializable
-        data object QueriesAndOperations : Configuration
+        data object IndexAndQuery : Configuration
 
         @Serializable
         data object Environment : Configuration
@@ -211,7 +211,7 @@ class DefaultMainComponent internal constructor(
         MainMenu.Security -> Configuration.Security
         MainMenu.Configsets -> Configuration.Configsets
         MainMenu.Collections -> Configuration.Collections
-        MainMenu.QueriesAndOperations -> Configuration.QueriesAndOperations
+        MainMenu.IndexAndQuery -> Configuration.IndexAndQuery
         MainMenu.Environment -> Configuration.Environment
         MainMenu.Logging -> Configuration.Logging
         MainMenu.ThreadDump -> Configuration.ThreadDump
diff --git a/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/MainMenu.kt b/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/MainMenu.kt
index 91a11d2161a..c1791069082 100644
--- a/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/MainMenu.kt
+++ b/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/MainMenu.kt
@@ -31,7 +31,7 @@ enum class MainMenu {
     Security,
     Configsets,
     Collections,
-    QueriesAndOperations,
+    IndexAndQuery,
     Environment,
     Logging,
     ThreadDump,
diff --git a/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationSideBar.kt b/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationSideBar.kt
index 86a5ada0eb4..06f79f96c2f 100644
--- a/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationSideBar.kt
+++ b/ui/shared/src/commonMain/kotlin/org/apache/solr/ui/views/navigation/NavigationSideBar.kt
@@ -58,7 +58,7 @@ import org.apache.solr.ui.shared.generated.resources.nav_dashboard
 import org.apache.solr.ui.shared.generated.resources.nav_environment
 import org.apache.solr.ui.shared.generated.resources.nav_logging
 import org.apache.solr.ui.shared.generated.resources.nav_metrics
-import org.apache.solr.ui.shared.generated.resources.nav_queries_and_operations
+import org.apache.solr.ui.shared.generated.resources.nav_index_and_query
 import org.apache.solr.ui.shared.generated.resources.nav_security
 import org.apache.solr.ui.shared.generated.resources.nav_thread_dump
 import org.apache.solr.ui.shared.generated.resources.other_admission
@@ -177,7 +177,7 @@ private fun getMainMenuText(item: MainMenu) = when (item) {
     MainMenu.Security -> Res.string.nav_security
     MainMenu.Configsets -> Res.string.nav_configsets
     MainMenu.Collections -> Res.string.nav_collections
-    MainMenu.QueriesAndOperations -> Res.string.nav_queries_and_operations
+    MainMenu.IndexAndQuery -> Res.string.nav_index_and_query
     MainMenu.Environment -> Res.string.nav_environment
     MainMenu.Logging -> Res.string.nav_logging
     MainMenu.ThreadDump -> Res.string.nav_thread_dump
@@ -200,7 +200,7 @@ private fun getMenuIcon(
 
     MainMenu.Collections -> Res.drawable.apps
 
-    MainMenu.QueriesAndOperations ->
+    MainMenu.IndexAndQuery ->
         if (isSelected) Res.drawable.other_admission_filled else Res.drawable.other_admission
 
     MainMenu.Environment -> if (isSelected) Res.drawable.memory_filled else Res.drawable.memory


(solr) 06/12: SOLR-13202: Return 400 instead of 500 for join queries missing from/to parameters (#5012)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit a9d489d2035d6ee920f2068f025c6217a934603a
Author: Nick <ni...@gmail.com>
AuthorDate: Wed Oct 7 09:03:48 2026 -0600

    SOLR-13202: Return 400 instead of 500 for join queries missing from/to parameters (#5012)
    
    (cherry picked from commit 93e357bcf6bc9b3b6195043529ef5e1fa2001fde)
---
 changelog/unreleased/SOLR-13202.yml                |  7 +++++++
 .../org/apache/solr/search/JoinQParserPlugin.java  |  2 ++
 .../search/join/AuxIndexJoinQParserPlugin.java     |  4 +---
 .../search/join/CrossCollectionJoinQParser.java    |  1 +
 .../solr/search/join/ScoreJoinQParserPlugin.java   | 13 ++++++++++++
 solr/core/src/test/org/apache/solr/TestJoin.java   | 15 ++++++++++++++
 .../search/join/CrossCollectionJoinQueryTest.java  | 23 ++++++++++++++++++++++
 .../search/join/TestAuxIndexJoinQParserPlugin.java | 11 +++++++----
 .../search/join/TestGlobalOrdinalsJoinQParser.java | 13 ++++++++++++
 9 files changed, 82 insertions(+), 7 deletions(-)

diff --git a/changelog/unreleased/SOLR-13202.yml b/changelog/unreleased/SOLR-13202.yml
new file mode 100644
index 00000000000..1d4ea052cec
--- /dev/null
+++ b/changelog/unreleased/SOLR-13202.yml
@@ -0,0 +1,7 @@
+title: Return 400 instead of 500 for join queries, including cross-collection joins, that are missing the from or to parameter
+type: fixed
+authors:
+  - name: Nick Shanin
+links:
+  - name: SOLR-13202
+    url: https://issues.apache.org/jira/browse/SOLR-13202
diff --git a/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java
index b403c0c92a2..343210cde7f 100644
--- a/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/JoinQParserPlugin.java
@@ -146,6 +146,8 @@ public class JoinQParserPlugin extends QParserPlugin {
       final String v = qparser.localParams.get(QueryParsing.V);
       final String coreName;
 
+      ScoreJoinQParserPlugin.requireFromAndTo(fromField, toField);
+
       Query fromQuery;
       long fromCoreOpenTime = 0;
 
diff --git a/solr/core/src/java/org/apache/solr/search/join/AuxIndexJoinQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/join/AuxIndexJoinQParserPlugin.java
index 9535dcf5226..0f7cfee6317 100644
--- a/solr/core/src/java/org/apache/solr/search/join/AuxIndexJoinQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/join/AuxIndexJoinQParserPlugin.java
@@ -408,9 +408,7 @@ public class AuxIndexJoinQParserPlugin extends QParserPlugin
         }
         final String fromField = getParam("from");
         final String toField = getParam("to");
-        if (fromField == null || toField == null) {
-          throw new SyntaxError("auxIndexJoin query parser requires 'from' and 'to' local params");
-        }
+        ScoreJoinQParserPlugin.requireFromAndTo(fromField, toField);
         final String fromIndex = localParams.get("fromIndex");
         final String v = localParams.get(CommonParams.VALUE);
         final String myCore = req.getCore().getCoreDescriptor().getName();
diff --git a/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java b/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
index 28b08154f57..4581facf15d 100644
--- a/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
+++ b/solr/core/src/java/org/apache/solr/search/join/CrossCollectionJoinQParser.java
@@ -106,6 +106,7 @@ public class CrossCollectionJoinQParser extends QParser {
     String collection = localParams.get(FROM_INDEX);
     String fromField = localParams.get(FROM);
     String toField = localParams.get(TO);
+    ScoreJoinQParserPlugin.requireFromAndTo(fromField, toField);
 
     boolean routedByJoinKey = localParams.getBool(ROUTED_BY_JOIN_KEY, toField.equals(routerField));
     int ttl = localParams.getInt(TTL, TTL_DEFAULT);
diff --git a/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java
index 29b4a44f432..08beafdc190 100644
--- a/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/join/ScoreJoinQParserPlugin.java
@@ -355,6 +355,18 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
     }
   }
 
+  /** Fails with a bad request if a join has no {@code from} or no {@code to} field. */
+  public static void requireFromAndTo(String fromField, String toField) {
+    if (fromField == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST, "Join query missing required 'from' parameter");
+    }
+    if (toField == null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST, "Join query missing required 'to' parameter");
+    }
+  }
+
   @Override
   public QParser createParser(
       String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) {
@@ -364,6 +376,7 @@ public class ScoreJoinQParserPlugin extends QParserPlugin {
         final String fromField = localParams.get("from");
         final String fromIndex = localParams.get("fromIndex");
         final String toField = localParams.get("to");
+        requireFromAndTo(fromField, toField);
         final ScoreMode scoreMode = ScoreModeParser.parse(getParam(SCORE));
 
         final String v = localParams.get(CommonParams.VALUE);
diff --git a/solr/core/src/test/org/apache/solr/TestJoin.java b/solr/core/src/test/org/apache/solr/TestJoin.java
index 14f776610ba..85e4b4f128b 100644
--- a/solr/core/src/test/org/apache/solr/TestJoin.java
+++ b/solr/core/src/test/org/apache/solr/TestJoin.java
@@ -490,4 +490,19 @@ public class TestJoin extends SolrTestCaseJ4 {
         return "{!join " + allProvidedParams + " method=topLevelDV}";
     }
   }
+
+  @Test
+  public void testJoinMissingFromToReturns400() {
+    for (String method :
+        List.of("", "method=dvWithScore score=none", "method=topLevelDV", "score=none")) {
+      assertMissingJoinParam("{!join to=id " + method + "}*:*", "from");
+      assertMissingJoinParam("{!join from=id " + method + "}*:*", "to");
+    }
+  }
+
+  private void assertMissingJoinParam(String query, String missing) {
+    SolrException e = expectThrows(SolrException.class, () -> h.query(req("q", query, "fl", "id")));
+    assertEquals(query, SolrException.ErrorCode.BAD_REQUEST.code, e.code());
+    assertTrue(e.getMessage(), e.getMessage().contains("'" + missing + "'"));
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java b/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
index 341be4d6223..2b6e50693fd 100644
--- a/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
+++ b/solr/core/src/test/org/apache/solr/search/join/CrossCollectionJoinQueryTest.java
@@ -30,6 +30,7 @@ import org.apache.solr.client.solrj.request.CollectionAdminRequest;
 import org.apache.solr.client.solrj.request.UpdateRequest;
 import org.apache.solr.client.solrj.response.QueryResponse;
 import org.apache.solr.cloud.SolrCloudTestCase;
+import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrInputDocument;
 import org.apache.solr.common.cloud.DocCollection;
 import org.apache.solr.common.cloud.Replica;
@@ -469,6 +470,28 @@ public class CrossCollectionJoinQueryTest extends SolrCloudTestCase {
     }
   }
 
+  @Test
+  public void testMissingFromOrToAtParser() throws Exception {
+    for (String missing : List.of(CrossCollectionJoinQParser.FROM, CrossCollectionJoinQParser.TO)) {
+      ModifiableSolrParams localParams = new ModifiableSolrParams();
+      localParams.set(QueryParsing.V, "*:*");
+      localParams.set(CrossCollectionJoinQParser.FROM_INDEX, "products");
+      localParams.set(CrossCollectionJoinQParser.FROM, "product_id_s");
+      localParams.set(CrossCollectionJoinQParser.TO, "product_id_s");
+      localParams.remove(missing);
+
+      ModifiableSolrParams requestParams = new ModifiableSolrParams();
+      try (SolrQueryRequest req = new SolrQueryRequestBase(null, requestParams) {}) {
+        CrossCollectionJoinQParser parser =
+            new CrossCollectionJoinQParser(
+                null, localParams, requestParams, req, "product_id_s", null);
+        SolrException e = expectThrows(SolrException.class, parser::parse);
+        assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code());
+        assertTrue(e.getMessage(), e.getMessage().contains("'" + missing + "'"));
+      }
+    }
+  }
+
   @Test
   public void testShardsPreferenceRequestParamPropagation() throws Exception {
     // shards.preference set as a request-level param (not in localParams) should be
diff --git a/solr/core/src/test/org/apache/solr/search/join/TestAuxIndexJoinQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/join/TestAuxIndexJoinQParserPlugin.java
index 4c1871f8d36..9685ed74bac 100644
--- a/solr/core/src/test/org/apache/solr/search/join/TestAuxIndexJoinQParserPlugin.java
+++ b/solr/core/src/test/org/apache/solr/search/join/TestAuxIndexJoinQParserPlugin.java
@@ -119,12 +119,15 @@ public class TestAuxIndexJoinQParserPlugin extends SolrTestCase {
 
   @Test
   public void testMissingLocalParams() {
+    assertMissingJoinParam("{!auxIndexJoin to=dept_id_s}*:*", "from");
+    assertMissingJoinParam("{!auxIndexJoin from=dept_s}*:*", "to");
+  }
+
+  private void assertMissingJoinParam(String query, String missing) {
     SolrException e =
-        expectThrows(
-            SolrException.class,
-            () -> solrRule.getSolrClient().query(params("q", "{!auxIndexJoin to=dept_id_s}*:*")));
+        expectThrows(SolrException.class, () -> solrRule.getSolrClient().query(params("q", query)));
     assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code());
-    assertTrue(e.getMessage(), e.getMessage().contains("requires"));
+    assertTrue(e.getMessage(), e.getMessage().contains("missing required '" + missing + "'"));
   }
 
   @Test
diff --git a/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java b/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java
index 7cd72a52516..04eebca7f54 100644
--- a/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java
+++ b/solr/core/src/test/org/apache/solr/search/join/TestGlobalOrdinalsJoinQParser.java
@@ -442,6 +442,19 @@ public class TestGlobalOrdinalsJoinQParser extends SolrTestCaseJ4 {
     assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
     assertTrue(ex.getMessage().contains("which"));
 
+    // Missing from query (no query body and no 'v' local param)
+    ex =
+        expectThrows(
+            SolrException.class,
+            () -> {
+              h.query(
+                  req(
+                      "q",
+                      "{!globalOrdinalsJoin joinField=join_s_dv which=\\\"type_s:parent\\\"}"));
+            });
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code());
+    assertTrue(ex.getMessage().contains("from query is required"));
+
     // Invalid score mode
     ex =
         expectThrows(


(solr) 10/12: Test fix: TestCollapseQParserPlugin

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit 9cace892d092410b7ffa3cda7fd0bdb9f2babe50
Author: David Smiley <ds...@apache.org>
AuthorDate: Wed Oct 7 15:32:17 2026 -0400

    Test fix: TestCollapseQParserPlugin
    
    Flaky due to docid re-order.
    
    (cherry picked from commit f0ec950113bce6430d3c08032697e6dde20701df)
---
 .../src/test/org/apache/solr/search/TestCollapseQParserPlugin.java     | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java
index d8284e1fd1d..a8166da7782 100644
--- a/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java
+++ b/solr/core/src/test/org/apache/solr/search/TestCollapseQParserPlugin.java
@@ -32,6 +32,7 @@ import org.apache.solr.common.SolrException;
 import org.apache.solr.common.SolrInputDocument;
 import org.apache.solr.common.params.ModifiableSolrParams;
 import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.index.LogDocMergePolicyFactory;
 import org.apache.solr.search.CollapsingQParserPlugin.GroupHeadSelector;
 import org.apache.solr.search.CollapsingQParserPlugin.GroupHeadSelectorType;
 import org.junit.Before;
@@ -44,6 +45,8 @@ public class TestCollapseQParserPlugin extends SolrTestCaseJ4 {
     // we need DVs on point fields to compute stats & facets
     if (Boolean.getBoolean(NUMERIC_POINTS_SYSPROP))
       System.setProperty(NUMERIC_DOCVALUES_SYSPROP, "true");
+    // tie-breaking on group head depends on docid order; avoid merges that reorder docs
+    systemSetPropertySolrTestsMergePolicyFactory(LogDocMergePolicyFactory.class.getName());
     initCore("solrconfig-collapseqparser.xml", "schema11.xml");
   }