Skip to content

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

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.

Activity

  1. TaylorMutch commented on Jun 10, 2026

    @TaylorMutch
    Collaborator

    This is an interesting idea! Can you expand on the kinds of use cases you have in mind?

  2. added
    questionFurther information is requested
    area:cliCLI-related work
    and removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Jun 10, 2026
  3. johntmyers commented on Jun 15, 2026

    @johntmyers
    Collaborator

    @benoitf we want to prioritize extendability features so am curious to get more feedback here. Also do you see a pathway to doing something similar for SDK interfaces (we still need our core SDK built out as well).

  4. benoitf commented on Jun 15, 2026

    @benoitf
    ContributorAuthor

    hello @TaylorMutch , @johntmyers

    the specific usage I had in my mind was to have an image builder fitting the need of my project. For now there are some pre-existing images or you need to build your own images but there is room for an intermediate way of like taking a devContainer requirement and building that image (+including the agent), [1] etc.

    But usually even without this specific case, IMHO there are usually some utilities/helpers that people want to bring in but as it may target only some specific drivers/use-cases it can't fit in a global sdk, but then having it integrated as part of the main cli allow an easy installation


    [1] there is an existing project there
    https://github.com/openkaiden/openshell-image-builder

  5. github-actions commented on Jun 30, 2026

    @github-actions

    This issue has had no activity for 14 days and is now marked stale. It may be closed in 7 days if there is no further activity. Comment or remove the state:stale label to keep it open.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:cliCLI-related workquestionFurther information is requestedstate:staleInactive item at risk of automatic closure.

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions