Skip to content

feat(cli): add PATH-based plugin discovery for external subcommands #1851

@benoitf

Description

@benoitf

Problem Statement

OpenShell CLI should be extensible via third-party plugins, following the same pattern used by git, kubectl, docker, and helm. If a binary named openshell-<name> exists on $PATH, then openshell <name> [args...] should discover and execute it, forwarding all remaining arguments. This enables the ecosystem to grow beyond built-in commands without requiring changes to the core CLI.

Technical Context

The CLI binary is named openshell (defined in crates/openshell-cli/Cargo.toml). It uses clap 4.x with #[derive(Parser)] and a Commands enum with 14 subcommand variants. Today, unknown subcommands produce a clap error (unrecognized subcommand) before any custom code runs. Clap 4.x has first-class support for external subcommands via allow_external_subcommands and an #[command(external_subcommand)] enum variant, making this a well-supported pattern.

Affected Components

Component Key Files Role
CLI entry point crates/openshell-cli/src/main.rs Parser struct, Commands enum, dispatch match
Help template crates/openshell-cli/src/main.rs:197-233 Custom grouped help text
Process exec patterns crates/openshell-cli/src/ssh.rs:218-242 Existing exec_or_wait pattern to reuse
CLI tests crates/openshell-cli/src/main.rs:3124-4575 ~90 parse/completion unit tests

Technical Investigation

Architecture Overview

main()
  ├── rustls crypto provider install
  ├── CompleteEnv::with_factory(Cli::command).complete()  // shell completions
  ├── Cli::parse()                                         // clap parses args
  ├── TlsOptions setup from global flags
  ├── tracing_subscriber init from --verbose
  └── match cli.command {
        Some(Commands::Gateway { .. }) => ...
        Some(Commands::Sandbox { .. }) => ...
        ...14 variants...
        None => print root help            ◄── insertion point
      }

Key clap configuration:

  • command: Option<Commands> — subcommand is optional
  • disable_help_subcommand = true, disable_help_flag = true — help manually managed
  • allow_external_subcommands is not currently set
  • Custom HELP_TEMPLATE groups commands into "SANDBOX COMMANDS", "GATEWAY COMMANDS", "ADDITIONAL COMMANDS"

Code References

Location Description
crates/openshell-cli/Cargo.toml:14 Binary name: openshell
crates/openshell-cli/src/main.rs:356-408 Cli struct with #[derive(Parser)]
crates/openshell-cli/src/main.rs:410-580 Commands enum with #[derive(Subcommand)] — 14 variants
crates/openshell-cli/src/main.rs:197-233 HELP_TEMPLATE — custom grouped help
crates/openshell-cli/src/main.rs:1887-3106 Main dispatch: match cli.command
crates/openshell-cli/src/main.rs:3100-3102 None arm — prints help for no subcommand
crates/openshell-cli/src/ssh.rs:218-242 exec_or_wait() — Unix process replacement pattern
crates/openshell-cli/src/ssh.rs:1472-1492 launch_editor_command() — spawn with NotFound handling
crates/openshell-cli/src/main.rs:3124-4575 Test module — ~90 parse/completion tests

Current Behavior

When a user types openshell foobar:

  1. Cli::parse() calls clap's parser
  2. Clap sees foobar doesn't match any Commands variant
  3. Clap prints error: unrecognized subcommand 'foobar' with suggestions and exits code 2
  4. Custom code in main() is never reached

What Would Need to Change

1. Clap configuration (Cli struct):

  • Add allow_external_subcommands = true to the #[command(...)] attribute
  • Add an #[command(external_subcommand)] External(Vec<OsString>) variant to the Commands enum

2. Plugin dispatch (new match arm in main()):

  • Extract the subcommand name and remaining args from the External(Vec<OsString>) variant
  • Look up openshell-<subcommand> on $PATH (via which crate or Command::new + error matching)
  • Set environment variables for global CLI context (gateway name, endpoint, verbose level)
  • On Unix: use exec() to replace the process (matches existing exec_or_wait pattern)
  • On non-Unix: spawn and forward exit code

3. Internal binary blocklist:

  • The workspace ships binaries like openshell-gateway, openshell-sandbox, openshell-driver-kubernetes, etc.
  • These are on $PATH in deployment scenarios but are NOT plugins
  • Need a blocklist to prevent openshell driver-kubernetes from exec'ing the driver binary

4. Help template update:

  • Add a "PLUGINS" section to HELP_TEMPLATE explaining that openshell-<name> binaries on PATH are available as openshell <name>

Alternative Approaches Considered

Approach Pros Cons Verdict
A. allow_external_subcommands Built into clap, minimal code, established pattern Changes error messages for genuine typos Recommended
B. Manual pre-parse No clap changes, preserves error messages Must handle global flags manually, fragile Not recommended
C. try_parse + fallback Preserves clap error messages for typos More complex; must re-handle parse failures Good fallback if typo UX matters

Patterns to Follow

  • Process replacement: Reuse the exec_or_wait pattern from ssh.rs:218-242 — Unix exec() for clean signal handling, Windows spawn+wait fallback
  • Error handling: Reuse the NotFound matching pattern from launch_editor_command in ssh.rs:1472-1492
  • Env var conventions: Follow existing OPENSHELL_* env var naming (gateway, gateway_endpoint, gateway_insecure)

Proposed Approach

Use clap's built-in allow_external_subcommands + #[command(external_subcommand)] enum variant. When an unknown subcommand is encountered, look up openshell-<name> on $PATH, propagate global CLI state as environment variables, and exec() the plugin on Unix (spawn+wait on Windows). Add a blocklist for internal binaries (openshell-gateway, openshell-sandbox, etc.) that are not plugins. Update the help template with a "PLUGINS" section.

Scope Assessment

  • Complexity: Low
  • Confidence: High — clear path, well-understood pattern, clap has first-class support
  • Estimated files to change: 2-3 (main.rs, Cargo.toml for which dep, possibly a new test file)
  • Issue type: feat

Risks & Open Questions

  • Internal binary collision: Binaries like openshell-gateway and openshell-sandbox are on PATH in deployments. A blocklist is needed, but the exact list should be reviewed — should it be hardcoded or derived from workspace Cargo.toml?
  • Auth token forwarding: Should plugins receive auth tokens via environment variables? This widens the trust boundary. Recommendation: do NOT pass tokens; let plugins resolve auth via openshell-bootstrap APIs. Needs human decision.
  • Shell completions: External subcommands won't appear in shell completions automatically. The clap_complete dynamic engine won't suggest plugin names. Acceptable for v1, but worth noting as a follow-up.
  • Error UX for typos: With allow_external_subcommands, clap no longer shows "did you mean?" suggestions for typos. The plugin-not-found error should include available built-in commands or suggest --help. Alternatively, use the try_parse fallback approach (Option C).
  • Security (accepted risk): Running arbitrary binaries from PATH is the same threat model as git/kubectl plugins — the user controls their PATH. No shell interpolation is used (Command::new is safe).

Test Considerations

  • Unit tests: Add parse tests verifying (1) external subcommands are captured in the External variant, (2) built-in commands take precedence, (3) global flags work with external subcommands
  • Integration test: Create a temporary openshell-test-plugin script on PATH, run openshell test-plugin --check, verify exit code and environment variables are forwarded
  • Existing tests: The ~90 existing parse/completion tests in main.rs::tests must continue to pass. The completions_engine_returns_candidates test should verify external subcommands don't leak into normal completion
  • Blocklist test: Verify that openshell driver-kubernetes (internal binary name) produces an error, not a plugin exec

Created by spike investigation. Use build-from-issue to plan and implement.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:cliCLI-related workquestionFurther information is requested
    No fields configured for Enhancement.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions