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:
Cli::parse() calls clap's parser
- Clap sees
foobar doesn't match any Commands variant
- Clap prints
error: unrecognized subcommand 'foobar' with suggestions and exits code 2
- 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.
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, thenopenshell <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 incrates/openshell-cli/Cargo.toml). It uses clap 4.x with#[derive(Parser)]and aCommandsenum 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 viaallow_external_subcommandsand an#[command(external_subcommand)]enum variant, making this a well-supported pattern.Affected Components
crates/openshell-cli/src/main.rscrates/openshell-cli/src/main.rs:197-233crates/openshell-cli/src/ssh.rs:218-242exec_or_waitpattern to reusecrates/openshell-cli/src/main.rs:3124-4575Technical Investigation
Architecture Overview
Key clap configuration:
command: Option<Commands>— subcommand is optionaldisable_help_subcommand = true,disable_help_flag = true— help manually managedallow_external_subcommandsis not currently setHELP_TEMPLATEgroups commands into "SANDBOX COMMANDS", "GATEWAY COMMANDS", "ADDITIONAL COMMANDS"Code References
crates/openshell-cli/Cargo.toml:14openshellcrates/openshell-cli/src/main.rs:356-408Clistruct with#[derive(Parser)]crates/openshell-cli/src/main.rs:410-580Commandsenum with#[derive(Subcommand)]— 14 variantscrates/openshell-cli/src/main.rs:197-233HELP_TEMPLATE— custom grouped helpcrates/openshell-cli/src/main.rs:1887-3106match cli.commandcrates/openshell-cli/src/main.rs:3100-3102Nonearm — prints help for no subcommandcrates/openshell-cli/src/ssh.rs:218-242exec_or_wait()— Unix process replacement patterncrates/openshell-cli/src/ssh.rs:1472-1492launch_editor_command()— spawn withNotFoundhandlingcrates/openshell-cli/src/main.rs:3124-4575Current Behavior
When a user types
openshell foobar:Cli::parse()calls clap's parserfoobardoesn't match anyCommandsvarianterror: unrecognized subcommand 'foobar'with suggestions and exits code 2main()is never reachedWhat Would Need to Change
1. Clap configuration (
Clistruct):allow_external_subcommands = trueto the#[command(...)]attribute#[command(external_subcommand)] External(Vec<OsString>)variant to theCommandsenum2. Plugin dispatch (new match arm in
main()):External(Vec<OsString>)variantopenshell-<subcommand>on$PATH(viawhichcrate orCommand::new+ error matching)exec()to replace the process (matches existingexec_or_waitpattern)3. Internal binary blocklist:
openshell-gateway,openshell-sandbox,openshell-driver-kubernetes, etc.$PATHin deployment scenarios but are NOT pluginsopenshell driver-kubernetesfrom exec'ing the driver binary4. Help template update:
HELP_TEMPLATEexplaining thatopenshell-<name>binaries on PATH are available asopenshell <name>Alternative Approaches Considered
allow_external_subcommandstry_parse+ fallbackPatterns to Follow
exec_or_waitpattern fromssh.rs:218-242— Unixexec()for clean signal handling, Windows spawn+wait fallbackNotFoundmatching pattern fromlaunch_editor_commandinssh.rs:1472-1492OPENSHELL_*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 upopenshell-<name>on$PATH, propagate global CLI state as environment variables, andexec()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
whichdep, possibly a new test file)featRisks & Open Questions
openshell-gatewayandopenshell-sandboxare on PATH in deployments. A blocklist is needed, but the exact list should be reviewed — should it be hardcoded or derived from workspaceCargo.toml?openshell-bootstrapAPIs. Needs human decision.clap_completedynamic engine won't suggest plugin names. Acceptable for v1, but worth noting as a follow-up.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 thetry_parsefallback approach (Option C).Command::newis safe).Test Considerations
Externalvariant, (2) built-in commands take precedence, (3) global flags work with external subcommandsopenshell-test-pluginscript on PATH, runopenshell test-plugin --check, verify exit code and environment variables are forwardedmain.rs::testsmust continue to pass. Thecompletions_engine_returns_candidatestest should verify external subcommands don't leak into normal completionopenshell driver-kubernetes(internal binary name) produces an error, not a plugin execCreated by spike investigation. Use
build-from-issueto plan and implement.