@@ -1674,13 +1674,18 @@ enum SandboxCommands {
16741674 /// For interactive shell sessions, use `sandbox connect` instead.
16751675 ///
16761676 /// Examples:
1677+ /// openshell sandbox exec my-sandbox -- ls -la /workspace
16771678 /// openshell sandbox exec --name my-sandbox -- ls -la /workspace
16781679 /// openshell sandbox exec -n my-sandbox --workdir /app -- python script.py
16791680 /// echo "hello" | openshell sandbox exec -n my-sandbox -- cat
16801681 #[ command( help_template = LEAF_HELP_TEMPLATE , next_help_heading = "FLAGS" ) ]
16811682 Exec {
16821683 /// Sandbox name (defaults to last-used sandbox).
1683- #[ arg( long, short = 'n' , add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
1684+ #[ arg( add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
1685+ sandbox : Option < String > ,
1686+
1687+ /// Sandbox name; same as the positional argument.
1688+ #[ arg( long, short = 'n' , conflicts_with = "sandbox" , add = ArgValueCompleter :: new( completers:: complete_sandbox_names) ) ]
16841689 name : Option < String > ,
16851690
16861691 /// Working directory inside the sandbox.
@@ -1717,8 +1722,8 @@ enum SandboxCommands {
17171722 #[ arg( long = "env" , value_name = "KEY=VALUE" ) ]
17181723 envs : Vec < String > ,
17191724
1720- /// Command and arguments to execute.
1721- #[ arg( required = true , trailing_var_arg = true , allow_hyphen_values = true ) ]
1725+ /// Command and arguments to execute, after `--` .
1726+ #[ arg( required = true , last = true ) ]
17221727 command : Vec < String > ,
17231728 } ,
17241729
@@ -3569,6 +3574,7 @@ async fn run_async() -> Result<()> {
35693574 let _ = save_last_sandbox ( & ctx. name , & cli. workspace , & name) ;
35703575 }
35713576 SandboxCommands :: Exec {
3577+ sandbox,
35723578 name,
35733579 workdir,
35743580 timeout,
@@ -3578,7 +3584,8 @@ async fn run_async() -> Result<()> {
35783584 command,
35793585 no_login_shell,
35803586 } => {
3581- let name = resolve_sandbox_name ( name, & ctx. name , & cli. workspace ) ?;
3587+ let name =
3588+ resolve_sandbox_name ( name. or ( sandbox) , & ctx. name , & cli. workspace ) ?;
35823589 // Resolve --tty / --no-tty into an Option<bool> override.
35833590 let tty_override = if no_tty {
35843591 Some ( false )
@@ -4408,6 +4415,89 @@ mod tests {
44084415 assert_eq ! ( provider, "work-github" ) ;
44094416 }
44104417
4418+ #[ test]
4419+ fn exec_grammar_requires_separator_before_remote_command ( ) {
4420+ use clap:: error:: ErrorKind ;
4421+
4422+ // Returns (target, command, tty) or the clap error kind.
4423+ let parse = |args : & [ & str ] | {
4424+ let mut argv = vec ! [ "openshell" , "sandbox" , "exec" ] ;
4425+ argv. extend ( args) ;
4426+ let cli = Cli :: try_parse_from ( argv) . map_err ( |e| e. kind ( ) ) ?;
4427+ let Some ( Commands :: Sandbox {
4428+ command :
4429+ Some ( SandboxCommands :: Exec {
4430+ sandbox,
4431+ name,
4432+ command,
4433+ tty,
4434+ ..
4435+ } ) ,
4436+ } ) = cli. command
4437+ else {
4438+ panic ! ( "expected sandbox exec command" ) ;
4439+ } ;
4440+ Ok :: < _ , ErrorKind > ( ( name. or ( sandbox) , command, tty) )
4441+ } ;
4442+ let check = |args : & [ & str ] , target : Option < & str > , command : & [ & str ] , tty : bool | {
4443+ let got = parse ( args) . unwrap_or_else ( |kind| panic ! ( "{args:?} failed: {kind:?}" ) ) ;
4444+ let command = command. iter ( ) . map ( ToString :: to_string) . collect ( ) ;
4445+ assert_eq ! ( got, ( target. map( str :: to_string) , command, tty) , "{args:?}" ) ;
4446+ } ;
4447+
4448+ check (
4449+ & [ "a" , "--" , "echo" , "hi" ] ,
4450+ Some ( "a" ) ,
4451+ & [ "echo" , "hi" ] ,
4452+ false ,
4453+ ) ;
4454+ check ( & [ "-n" , "a" , "--" , "echo" ] , Some ( "a" ) , & [ "echo" ] , false ) ;
4455+ check ( & [ "--name" , "a" , "--" , "echo" ] , Some ( "a" ) , & [ "echo" ] , false ) ;
4456+ check ( & [ "--" , "echo" , "hi" ] , None , & [ "echo" , "hi" ] , false ) ;
4457+ // Flags on either side of the positional target.
4458+ check ( & [ "--tty" , "a" , "--" , "echo" ] , Some ( "a" ) , & [ "echo" ] , true ) ;
4459+ check ( & [ "a" , "--tty" , "--" , "echo" ] , Some ( "a" ) , & [ "echo" ] , true ) ;
4460+ check (
4461+ & [ "-n" , "a" , "--tty" , "--" , "echo" ] ,
4462+ Some ( "a" ) ,
4463+ & [ "echo" ] ,
4464+ true ,
4465+ ) ;
4466+ // Hyphenated remote args are opaque.
4467+ check ( & [ "a" , "--" , "ls" , "-la" ] , Some ( "a" ) , & [ "ls" , "-la" ] , false ) ;
4468+ check ( & [ "a" , "--" , "--tty" ] , Some ( "a" ) , & [ "--tty" ] , false ) ;
4469+ check ( & [ "--" , "-n" , "x" ] , None , & [ "-n" , "x" ] , false ) ;
4470+ // An inner delimiter belongs to the remote command.
4471+ let git = [ "git" , "log" , "--" , "path" ] ;
4472+ check (
4473+ & [ "a" , "--" , "git" , "log" , "--" , "path" ] ,
4474+ Some ( "a" ) ,
4475+ & git,
4476+ false ,
4477+ ) ;
4478+ check ( & [ "--" , "git" , "log" , "--" , "path" ] , None , & git, false ) ;
4479+
4480+ let err: & [ ( & [ & str ] , ErrorKind ) ] = & [
4481+ // Target given twice.
4482+ ( & [ "-n" , "a" , "b" , "--" , "echo" ] , ErrorKind :: ArgumentConflict ) ,
4483+ ( & [ "b" , "-n" , "a" , "--" , "echo" ] , ErrorKind :: ArgumentConflict ) ,
4484+ // Missing `--`.
4485+ ( & [ "a" , "echo" , "hi" ] , ErrorKind :: UnknownArgument ) ,
4486+ ( & [ "a" , "--tty" , "echo" ] , ErrorKind :: UnknownArgument ) ,
4487+ ( & [ "-n" , "a" , "echo" , "hi" ] , ErrorKind :: UnknownArgument ) ,
4488+ ( & [ "git" , "log" ] , ErrorKind :: UnknownArgument ) ,
4489+ ( & [ "a" ] , ErrorKind :: MissingRequiredArgument ) ,
4490+ ( & [ ] , ErrorKind :: MissingRequiredArgument ) ,
4491+ // Missing remote command.
4492+ ( & [ "a" , "--" ] , ErrorKind :: MissingRequiredArgument ) ,
4493+ ( & [ "-n" , "a" , "--" ] , ErrorKind :: MissingRequiredArgument ) ,
4494+ ( & [ "--" ] , ErrorKind :: MissingRequiredArgument ) ,
4495+ ] ;
4496+ for ( args, kind) in err {
4497+ assert_eq ! ( parse( args) . map( |_| ( ) ) , Err ( * kind) , "{args:?}" ) ;
4498+ }
4499+ }
4500+
44114501 #[ test]
44124502 fn provider_readiness_commands_have_bounded_waits_and_structured_output ( ) {
44134503 for action in [ "attach" , "detach" , "status" ] {
0 commit comments