Skip to content

refactor: new CLI with picocli#2104

Draft
pirhoo wants to merge 18 commits intomainfrom
refactor/cli
Draft

refactor: new CLI with picocli#2104
pirhoo wants to merge 18 commits intomainfrom
refactor/cli

Conversation

@pirhoo
Copy link
Copy Markdown
Member

@pirhoo pirhoo commented Apr 2, 2026

Introduce a picocli-based subcommand CLI architecture to replace the flat, flag-based jopt-simple interface. The legacy jopt-simple path is fully preserved and routed via Main.isLegacyInvocation(), ensuring backward compatibility during the transition. Several code quality improvements are included alongside the new CLI infrastructure.

Changes

  • feat: picocli subcommand hierarchy (app, worker, stage, plugin, extension, api-key) with leaf subcommands
  • feat: dual-path invocation — isLegacyInvocation() routes between jopt-simple and picocli
  • feat: global options accepted at any argument position via ScopeType.INHERIT on GlobalOptions
  • feat: DatashareHelpFactory with two-column layout, alphabetical sorting, and a dedicated Global Options section
  • refactor: split Main.main() into focused private methods
  • refactor: simplify DatashareHelpFactory.apply() with headedSection, ownOptionRows, globalOptionRows helpers
  • refactor: extract shared putIfNotNull to DatashareOptions; add OAUTH_CLAIM_ID_ATTRIBUTE_OPT constant
  • fix: resource leak in DatashareVersionProvider (try-with-resources)
  • fix: empty defaultProject now falls back to local-datashare instead of passing through
  • test: global option position, option name conflict detection, empty defaultProject fallback, legacy invocation routing

Breaking changes

The new subcommand interface is not yet the default path (the legacy invocation is still selected automatically for existing callers). However, users who integrate with the CLI programmatically or parse its output should be aware:

  • The new picocli path produces a structurally different help output (two-column layout, separate Global Options section) compared to the jopt-simple --help output
  • Subcommand names (app start, worker run, stage run, plugin list/install/delete, etc.) are new entry points that do not exist in the legacy interface; tooling that wraps the CLI will need updating when the legacy path is eventually removed

Architecture

Invocation routing

Main.main() is now a two-liner that delegates to resolveProperties(), which picks the right parser:

flowchart TD
    A["./datashare <args>"] --> B{isLegacyInvocation?}

    B -->|"no args\nfirst arg starts with -\nfirst arg not a known subcommand"| C[Legacy path\nDatashareCli / jopt-simple]
    B -->|"first arg is app / worker /\nstage / plugin / extension /\napi-key / help"| D[Picocli path\nrunPicocli]

    C --> E[cli.properties]
    D --> F[cmd.collectProperties]
    E --> G[startApplication]
    F --> G
Loading

Subcommand tree

datashare [GLOBAL OPTIONS]
├── app
│   └── start      ← AppServeCommand    (mode, HTTP server, OAuth, batch…)
├── worker
│   └── run        ← WorkerRunCommand   (task worker)
├── stage
│   └── run        ← StageRunCommand    (pipeline stages)
├── plugin
│   ├── list       ← PluginListCommand
│   ├── install    ← PluginInstallCommand
│   └── delete     ← PluginDeleteCommand
├── extension
│   ├── list       ← ExtensionListCommand
│   ├── install    ← ExtensionInstallCommand
│   └── delete     ← ExtensionDeleteCommand
├── api-key
│   ├── create     ← ApiKeyCreateCommand
│   ├── get        ← ApiKeyGetCommand
│   └── delete     ← ApiKeyDeleteCommand
└── help

Parent commands (app, worker, etc.) print usage when invoked without a subcommand. All leaf commands implement both Runnable and DatashareSubcommand.

Property assembly

flowchart LR
    G[GlobalOptions.toProperties] --> M[DatashareCommand\n.collectProperties]
    S[LeafCommand.getSubcommandProperties] -->|"overrides globals"| M
    M --> P[DatashareOptions.postProcess\ndigestProjectName · oauthUserProjectsAttribute · port alias]
    P --> R[Properties passed to\nCommonMode.create]
Loading

Subcommand properties are merged last, so subcommand-specific values override global defaults.

Global options and position independence

Every option in GlobalOptions carries scope = ScopeType.INHERIT. picocli propagates them down to every leaf command spec, so they are accepted at any position:

datashare --elasticsearchAddress http://es:9200 app start
datashare app start --elasticsearchAddress http://es:9200   # identical result

DatashareHelpFactory filters o.inherited() from each subcommand's own option list to prevent duplicate rendering, then renders inherited options in a separate Global Options section.

Help rendering

DatashareHelpFactory applies a yarn-inspired style to every command in the tree:

Section Content
Synopsis Abbreviated ([OPTIONS])
Description Plain text; lines ending : are auto-bolded as headings
Arguments Positional parameters (leaf commands only)
Options Command-specific options (leaf commands only, inherited excluded)
Global Options Options from GlobalOptions (leaf subcommands only)
Commands Subcommand list (parent commands only)

Column widths are sized to the widest option label across both the Options and Global Options sections so both blocks align. Options are sorted alphabetically by long name.

Shell script routing

The datashare launch script detects the invocation style from the first argument:

┌───────────────────────────────────────────────────────────────────┐
│  first arg = app / worker / stage / plugin / extension / api-key  │
│                          │                                        │
│           ┌──────────────┴──────────────┐                         │
│     Subcommand path               Legacy path                     │
│     inject ES paths               inject all defaults             │
│     pass "$@" to Java             pass "$@" to Java               │
└───────────────────────────────────────────────────────────────────┘

Both paths inject --elasticsearchPath, --elasticsearchSettings, and --elasticsearchDataPath from shell-resolved defaults ($DATASHARE_HOME). User-provided options are appended last and override the injected defaults (picocli's setOverwrittenOptionsAllowed(true)). ES setup runs for --mode EMBEDDED in both paths.

pirhoo added 18 commits April 1, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant