Skip to content

Commit 07a486d

Browse files
ericcurtindrew
andauthored
fix(cli): accept sandbox name before -- in exec (#3901)
* fix(cli): accept sandbox name before -- in exec Closes #3882 Signed-off-by: Eric Curtin <eric.curtin@docker.com> * fix(cli): define exec grammar in clap Signed-off-by: Eric Curtin <eric.curtin@docker.com> * docs(sandboxes): remove exec overview change from PR Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Eric Curtin <eric.curtin@docker.com> Signed-off-by: Drew Newberry <anewberry@nvidia.com> Co-authored-by: Drew Newberry <anewberry@nvidia.com>
1 parent 7caff12 commit 07a486d

2 files changed

Lines changed: 99 additions & 5 deletions

File tree

‎crates/openshell-cli/src/main.rs‎

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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"] {

‎skills/openshell-cli/SKILL.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,10 +411,14 @@ within it.
411411
### Execute a non-interactive command
412412

413413
```bash
414-
openshell sandbox exec --name my-sandbox --workdir /workspace -- ls -la
414+
openshell sandbox exec my-sandbox --workdir /workspace -- ls -la
415415
openshell sandbox exec --name my-sandbox --env MODE=test -- cargo test
416416
```
417417

418+
The sandbox is a positional name or `--name`, not both; omit it to use the
419+
last-used sandbox. `--` is required and everything after it is the remote
420+
command, so put options such as `--tty` before it.
421+
418422
`sandbox exec` starts an independent sibling process and streams output. After
419423
stdout and stderr drain, it returns the remote command's exit code if delivery
420424
succeeds. Output delivery failure instead returns exit code 74, even when the

0 commit comments

Comments
 (0)