Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 2 additions & 19 deletions cli/src/cli/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@ use std::collections::BTreeMap;
use std::env;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;

use clap::Args;
use itertools::Itertools;
use miette::IntoDiagnostic;
use std::sync::LazyLock;
use xx::process::check_status;
use xx::{regex, XXError, XXResult};
use xx::regex;

use usage::parse::{ParseOutput, ParseValue};
use usage::sh::sh;
use usage::{Spec, SpecArg, SpecCommand, SpecComplete, SpecFlag};

use crate::cli::generate;
Expand Down Expand Up @@ -410,19 +409,3 @@ fn zsh_shell_quote(s: &str) -> String {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}

fn sh(script: &str) -> XXResult<String> {
let output = Command::new("sh")
.arg("-c")
.arg(script)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::inherit())
.env("__USAGE", env!("CARGO_PKG_VERSION"))
.output()
.map_err(|err| XXError::ProcessError(err, format!("sh -c {script}")))?;

check_status(output.status)
.map_err(|err| XXError::ProcessError(err, format!("sh -c {script}")))?;
let stdout = String::from_utf8(output.stdout).expect("stdout is not utf-8");
Ok(stdout)
}
43 changes: 43 additions & 0 deletions cli/tests/complete_word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ use assert_cmd::prelude::*;
use predicates::prelude::*;
use predicates::str::contains;

/// Returns `true` if the test should be skipped because there is no POSIX shell on PATH.
/// Panics under `CI` (any non-empty value) so a missing shell there is a configuration bug
/// rather than a silent pass.
///
/// The mount fixtures are `#!/usr/bin/env -S usage bash` scripts, so `mount run=` can only
/// work where `sh` can start them. Without this guard, Windows machines with no POSIX shell
/// fall through to the `cmd /c` path, which hands a `.sh` to whatever program is registered
/// for the extension — an editor window per test rather than a failure.
fn skip_if_posix_shell_missing() -> bool {
if Command::new("sh").arg("-c").arg("exit 0").output().is_ok() {
return false;
}
if env::var("CI").is_ok_and(|v| !v.is_empty()) {
panic!("no POSIX shell (`sh`) on PATH but CI is set — refusing to skip");
}
eprintln!("Skipping test - no POSIX shell (`sh`) on PATH");
true
}

#[test]
fn complete_word_completer() {
assert_cmd("basic.usage.kdl", &["plugins", "install", "pl"])
Expand Down Expand Up @@ -132,6 +151,9 @@ fn complete_word_arg_completer() {

#[test]
fn complete_word_mounted() {
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand All @@ -150,6 +172,9 @@ fn complete_word_mounted() {

#[test]
fn complete_word_mounted_with_global_flags() {
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down Expand Up @@ -191,6 +216,9 @@ fn complete_word_mounted_global_flag_choices() {
// Regression for the parser-side root cause referenced by jdx/mise#10069:
// a value-taking global flag placed before a mounted subcommand must not leak its
// consumed tokens into the mounted task's `choices` positional arg.
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down Expand Up @@ -242,6 +270,9 @@ fn complete_word_mounted_orphan_short_flag_choices() {
// Completing a mounted task with the short in front must return the task's choices rather
// than bailing with "unexpected word" / "Invalid choice" (which mise worked around by
// promoting the short back to global).
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down Expand Up @@ -287,6 +318,9 @@ fn complete_word_mounted_orphan_short_flag_choices() {
fn complete_word_mounted_does_not_offer_mounting_cli_flags() {
// Regression for jdx/mise#11282: the mounting CLI's global flags must not be offered
// inside a mounted command, and must not shadow the mounted command's own flags.
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down Expand Up @@ -376,6 +410,9 @@ fn complete_word_mounted_does_not_offer_mounting_cli_flags() {

#[test]
fn complete_word_boolean_flags_dont_consume_subcommands() {
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down Expand Up @@ -410,6 +447,9 @@ fn complete_word_boolean_flags_dont_consume_subcommands() {

#[test]
fn complete_word_non_global_flags_do_not_stop_search() {
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down Expand Up @@ -439,6 +479,9 @@ fn complete_word_non_global_flags_do_not_stop_search() {

#[test]
fn complete_word_mixed_global_flags() {
if skip_if_posix_shell_missing() {
return;
}
let mut path = env::split_paths(&env::var("PATH").unwrap()).collect::<Vec<_>>();
path.insert(
0,
Expand Down
5 changes: 5 additions & 0 deletions docs/spec/reference/cmd.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ Now when using completion with usage, if the user types `mycli run <tab><tab>`,
call `mycli mount-usage-tasks` and merge the emitted usage into the `run` command and display the
task commands as if they were statically defined in the usage spec.

`mount run` is executed the same way as [`complete`'s `run`](./complete.md#which-shell-runs-run):
`sh -c`, falling back to `cmd /c` on Windows when there is no `sh` on `PATH`. A mount pointing at
a shebang script therefore needs a POSIX shell to be available; one that invokes a program
directly, like `mycli mount-usage-tasks` above, works either way.

### Global flags and mounted commands

A mounted command describes a different program, so the flags of the commands it is mounted under
Expand Down
13 changes: 13 additions & 0 deletions docs/spec/reference/complete.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,16 @@ complete "four" run="echo {{ words | slice(start=-4) | join(sep='\"\n\"') }}"
```

Here we just use simple commands like `ls` and `echo` but these words could be passed to any command.

## Which shell runs `run`

`run` is executed with `sh -c`, so it is a POSIX shell command line: pipelines, `;` sequences
and shell builtins all work.

On Windows a POSIX shell is not guaranteed. usage still runs `sh -c` when `sh` is on `PATH`
(Git for Windows provides it), and falls back to `cmd /c` when it is not. `cmd` cannot run any
of the above — it only handles a plain command invocation — so a spec that targets Windows
should either keep `run` to a single command or state that it needs a POSIX shell.

The script is run with stdin closed and stderr inherited, and with `__USAGE` set to the usage
version so a script can tell it was invoked by usage.
2 changes: 1 addition & 1 deletion lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ pub use error::Result;
pub mod docs;
pub mod parse;
pub mod sdk;
pub(crate) mod sh;
pub mod sh;
pub(crate) mod string;
#[cfg(test)]
mod test;
Loading