diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 3189f5ce..8f8ebdc3 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -24,5 +24,12 @@ jobs: shared-key: test save-if: false - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + - name: Install shells for completion integration tests + run: | + sudo apt-get update + sudo apt-get install -y zsh fish + if ! command -v pwsh >/dev/null 2>&1; then + sudo snap install powershell --classic + fi - run: mise r autofix - uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index d9fb20b8..7f97839d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,6 +23,13 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Install cargo-llvm-cov uses: taiki-e/install-action@fc9eae0a7877a2f4152f841721d1d1aa8c3f1a27 # cargo-llvm-cov + - name: Install shells for completion integration tests + run: | + sudo apt-get update + sudo apt-get install -y zsh fish + if ! command -v pwsh >/dev/null 2>&1; then + sudo snap install powershell --classic + fi - name: Generate code coverage run: mise run coverage - name: Upload coverage to Codecov diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2344aff9..30537546 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,16 @@ jobs: with: shared-key: test - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4 + - name: Install shells for completion integration tests + run: | + sudo apt-get update + sudo apt-get install -y zsh fish + # pwsh is pre-installed on GitHub ubuntu-latest images. Self-heal + # if a future image drops it so the integration test still runs + # (rather than panicking under CI=1). + if ! command -v pwsh >/dev/null 2>&1; then + sudo snap install powershell --classic + fi - run: mise r build - run: mise r test - run: mise r lint diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index b6aa3334..433bb830 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -286,6 +286,27 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ["completion-init", "ci"], + description: + "Generate a shell init script that auto-completes any usage shebang script on $PATH", + options: [ + { + name: "--usage-bin", + description: + "Override the bin used for calling back to usage-cli", + isRepeatable: false, + args: { + name: "usage_bin", + }, + }, + ], + args: { + name: "shell", + description: "Shell to generate the init script for", + suggestions: ["bash", "fish", "zsh"], + }, + }, { name: "fig", description: "Generate Fig completion spec for Amazon Q / Fig", diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 95bf8745..6320211c 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -46,6 +46,12 @@ Generate shell completion scripts for bash, fish, nu, powershell, or zsh \fIAliases: \fRc .RE .TP +\fBgenerate completion\-init\fR +Generate a shell init script that auto\-completes any usage shebang script on $PATH +.RS +\fIAliases: \fRci +.RE +.TP \fBgenerate fig\fR Generate Fig completion spec for Amazon Q / Fig .TP @@ -206,6 +212,28 @@ Shell to generate completions for .TP \fB\fR The CLI which we're generating completions for +.SH "USAGE GENERATE COMPLETION-INIT" +Generate a shell init script that auto\-completes any usage shebang script on $PATH + +Source the output once from your shell rc (e.g. ~/.bashrc) to enable tab\-completion for any executable whose first line is a `usage` shebang — no per\-script `usage g completion` step required. +.PP +\fBUsage:\fR usage generate completion\-init [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-\-usage\-bin\fR \fI\fR +Override the bin used for calling back to usage\-cli + +You may need to set this if you have a different bin named "usage" +.RS +\fIDefault: \fRusage +.RE +\fBArguments:\fR +.PP +.TP +\fB\fR +Shell to generate the init script for .SH "USAGE GENERATE FIG" Generate Fig completion spec for Amazon Q / Fig .PP diff --git a/cli/src/cli/generate/completion_init.rs b/cli/src/cli/generate/completion_init.rs new file mode 100644 index 00000000..908fe2f5 --- /dev/null +++ b/cli/src/cli/generate/completion_init.rs @@ -0,0 +1,28 @@ +use clap::Args; +use usage::complete::complete_init; + +/// Generate a shell init script that auto-completes any usage shebang script on $PATH +/// +/// Source the output once from your shell rc (e.g. ~/.bashrc) to enable +/// tab-completion for any executable whose first line is a `usage` shebang — +/// no per-script `usage g completion` step required. +#[derive(Args)] +#[clap(visible_alias = "ci", aliases = ["init", "completions-init"])] +pub struct CompletionInit { + /// Shell to generate the init script for + #[clap(value_parser = ["bash", "fish", "zsh"])] + shell: String, + + /// Override the bin used for calling back to usage-cli + /// + /// You may need to set this if you have a different bin named "usage" + #[clap(long, default_value = "usage", env = "JDX_USAGE_BIN")] + usage_bin: String, +} + +impl CompletionInit { + pub fn run(&self) -> miette::Result<()> { + println!("{}", complete_init(&self.shell, &self.usage_bin)?.trim()); + Ok(()) + } +} diff --git a/cli/src/cli/generate/mod.rs b/cli/src/cli/generate/mod.rs index 6881df8b..4943bc8b 100644 --- a/cli/src/cli/generate/mod.rs +++ b/cli/src/cli/generate/mod.rs @@ -5,6 +5,7 @@ use usage::error::UsageErr; use usage::Spec; mod completion; +mod completion_init; mod fig; mod json; mod manpage; @@ -21,6 +22,7 @@ pub struct Generate { #[derive(clap::Subcommand)] pub enum Command { Completion(completion::Completion), + CompletionInit(completion_init::CompletionInit), Fig(fig::Fig), Json(json::Json), Manpage(manpage::Manpage), @@ -31,6 +33,7 @@ impl Generate { pub fn run(&self) -> miette::Result<()> { match &self.command { Command::Completion(cmd) => cmd.run(), + Command::CompletionInit(cmd) => cmd.run(), Command::Fig(cmd) => cmd.run(), Command::Json(cmd) => cmd.run(), Command::Manpage(cmd) => cmd.run(), diff --git a/cli/tests/shell_completions_integration.rs b/cli/tests/shell_completions_integration.rs index 51b5dd92..7f92851f 100644 --- a/cli/tests/shell_completions_integration.rs +++ b/cli/tests/shell_completions_integration.rs @@ -3,6 +3,21 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +/// Returns `true` if the test should be skipped because the shell is not +/// installed. Panics under `CI=1` (or any non-empty `CI`) to prevent silent +/// false-positives in CI: if a shell is missing in CI it's a configuration +/// bug, not an excuse to skip the test. +fn skip_if_shell_missing(shell: &str) -> bool { + if Command::new(shell).arg("--version").output().is_ok() { + return false; + } + if env::var("CI").is_ok_and(|v| !v.is_empty()) { + panic!("shell `{shell}` not installed but CI is set — refusing to skip"); + } + eprintln!("Skipping {shell} test - {shell} shell not installed"); + true +} + /// Helper to run usage complete-word and return stdout fn run_complete_word(usage_bin: &Path, shell: &str, spec_file: &Path, words: &[&str]) -> String { let mut args = vec![ @@ -58,9 +73,7 @@ fn build_usage_binary() -> PathBuf { #[test] fn test_fish_completion_integration() { - // Skip if fish is not installed - if Command::new("fish").arg("--version").output().is_err() { - eprintln!("Skipping fish test - fish shell not installed"); + if skip_if_shell_missing("fish") { return; } @@ -209,9 +222,7 @@ echo "COMPLETION_TEST_DONE" #[test] fn test_bash_completion_integration() { - // Skip if bash is not installed - if Command::new("bash").arg("--version").output().is_err() { - eprintln!("Skipping bash test - bash shell not installed"); + if skip_if_shell_missing("bash") { return; } @@ -399,9 +410,7 @@ echo "COMPLETION_TEST_DONE" #[test] fn test_zsh_completion_integration() { - // Skip if zsh is not installed - if Command::new("zsh").arg("--version").output().is_err() { - eprintln!("Skipping zsh test - zsh shell not installed"); + if skip_if_shell_missing("zsh") { return; } @@ -558,9 +567,7 @@ echo "COMPLETION_TEST_DONE" #[test] fn test_powershell_completion_integration() { - // Skip if pwsh is not installed - if Command::new("pwsh").arg("--version").output().is_err() { - eprintln!("Skipping pwsh test - PowerShell Core not installed"); + if skip_if_shell_missing("pwsh") { return; } @@ -739,6 +746,241 @@ cmd other help="Another subcommand" let _ = fs::remove_dir_all(&temp_dir); } +/// Stage a `usage`-shebang test script onto a temp `bin/` directory and +/// generate the `g completion-init ` output. Returns (temp_dir, +/// bin_dir, init_script_path). +fn stage_init_test_env(usage_bin: &Path, shell: &str, label: &str) -> (PathBuf, PathBuf, PathBuf) { + let temp_dir = env::temp_dir().join(format!("usage_{label}_init_test_{}", std::process::id())); + let bin_dir = temp_dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + + let script = "\ +#!/usr/bin/env -S usage bash +#USAGE bin \"ex\" +#USAGE flag \"--foo\" help=\"Flag value\" +#USAGE arg \"baz\" help=\"Positional values\" +#USAGE complete \"baz\" run=\"echo val-1; echo val-2; echo val-3\" +echo baz: $usage_baz +"; + let script_path = bin_dir.join("ex"); + fs::write(&script_path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + } + + // Generate the init script + let output = Command::new(usage_bin) + .args(["generate", "completion-init", shell]) + .output() + .expect("Failed to generate completion-init"); + assert!( + output.status.success(), + "completion-init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let init_script = temp_dir.join(format!("init.{shell}")); + fs::write(&init_script, &output.stdout).unwrap(); + + (temp_dir, bin_dir, init_script) +} + +#[test] +fn test_bash_completion_init_integration() { + if skip_if_shell_missing("bash") { + return; + } + + let usage_bin = build_usage_binary(); + let (temp_dir, bin_dir, init_script) = stage_init_test_env(&usage_bin, "bash", "bash"); + + // Drive `_usage_default_complete` directly with simulated COMP_WORDS/CWORD. + // This mirrors what bash does at `` time. + let test_script = format!( + r#"#!/usr/bin/env bash +set -e +export PATH="{bin_dir}:{usage_dir}:$PATH" +source "{init_script}" + +run_case() {{ + local label="$1"; shift + local cword="$1"; shift + COMP_WORDS=("$@") + COMP_CWORD="$cword" + COMPREPLY=() + _usage_default_complete "${{COMP_WORDS[0]}}" "${{COMP_WORDS[$cword]}}" "${{COMP_WORDS[$((cword-1))]:-}}" + echo "[$label] ${{COMPREPLY[*]}}" +}} + +run_case empty 1 ex "" +run_case dashes 1 ex "--" +run_case foo 1 ex "--f" +"#, + bin_dir = bin_dir.display(), + usage_dir = usage_bin.parent().unwrap().display(), + init_script = init_script.display(), + ); + let script_file = temp_dir.join("test.sh"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("bash") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run bash init test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + println!("bash init stdout:\n{stdout}\nstderr:\n{stderr}"); + + assert!( + result.status.success(), + "bash init script exited non-zero. stderr: {stderr}" + ); + assert!( + stdout.contains("[empty] val-1 val-2 val-3"), + "expected positional completion, got: {stdout}" + ); + assert!( + stdout.contains("[dashes] --foo"), + "expected flag listing for `--`, got: {stdout}" + ); + assert!( + stdout.contains("[foo] --foo"), + "expected `--foo` for `--f`, got: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_zsh_completion_init_integration() { + if skip_if_shell_missing("zsh") { + return; + } + + let usage_bin = build_usage_binary(); + let (temp_dir, bin_dir, init_script) = stage_init_test_env(&usage_bin, "zsh", "zsh"); + + // Stub `_describe`/`_files` to capture what the handler offers without + // needing an interactive ZLE context. Drive with $words/$CURRENT. + let test_script = format!( + r#"#!/usr/bin/env zsh +set -e +export PATH="{bin_dir}:{usage_dir}:$PATH" +autoload -U compinit +compinit -u +source "{init_script}" + +_describe() {{ + local arr_name="$2" + local -a items + items=("${{(@P)arr_name}}") + print -r -- "[describe:$1] ${{items[*]}}" +}} +_files() {{ print -r -- "[files-fallback]" }} + +words=(ex "") +CURRENT=2 +_usage_default_complete + +words=(ex "--f") +CURRENT=2 +_usage_default_complete +"#, + bin_dir = bin_dir.display(), + usage_dir = usage_bin.parent().unwrap().display(), + init_script = init_script.display(), + ); + let script_file = temp_dir.join("test.zsh"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("zsh") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run zsh init test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + println!("zsh init stdout:\n{stdout}\nstderr:\n{stderr}"); + + assert!( + result.status.success(), + "zsh init script exited non-zero. stderr: {stderr}" + ); + assert!( + stdout.contains("[describe:completions] val-1 val-2 val-3"), + "expected positional completions via _describe, got: {stdout}" + ); + assert!( + stdout.contains("--foo:Flag value"), + "expected --foo flag with description in _describe items, got: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_fish_completion_init_integration() { + if skip_if_shell_missing("fish") { + return; + } + + let usage_bin = build_usage_binary(); + let (temp_dir, bin_dir, init_script) = stage_init_test_env(&usage_bin, "fish", "fish"); + + // Fish: source the init (which scans $PATH), then verify `complete -C` + // produces the expected output. We restrict $PATH to the test bin dir + // plus coreutils so the scan stays bounded. + let test_script = format!( + r#"#!/usr/bin/env fish +set -gx PATH "{bin_dir}" "{usage_dir}" /usr/bin /bin +source "{init_script}" + +if not complete -c ex | string match -q -- '*usage*' + echo "FAIL: completion not registered for ex" + exit 1 +end +echo "[registered] ex" + +echo "[empty]" (complete -C 'ex ') +echo "[foo]" (complete -C 'ex --f') +"#, + bin_dir = bin_dir.display(), + usage_dir = usage_bin.parent().unwrap().display(), + init_script = init_script.display(), + ); + let script_file = temp_dir.join("test.fish"); + fs::write(&script_file, &test_script).unwrap(); + + let result = Command::new("fish") + .arg(script_file.to_str().unwrap()) + .output() + .expect("Failed to run fish init test"); + let stdout = String::from_utf8_lossy(&result.stdout); + let stderr = String::from_utf8_lossy(&result.stderr); + println!("fish init stdout:\n{stdout}\nstderr:\n{stderr}"); + + assert!( + result.status.success(), + "fish init script exited non-zero. stderr: {stderr}" + ); + assert!( + stdout.contains("[registered] ex"), + "expected `ex` to be registered, got: {stdout}" + ); + assert!( + stdout.contains("[empty] val-1 val-2 val-3"), + "expected positional completion, got: {stdout}" + ); + assert!( + stdout.contains("--foo"), + "expected --foo for `--f`, got: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + #[test] fn test_complete_path_adds_trailing_slash_for_directories() { let usage_bin = build_usage_binary(); diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index be20bdd2..6a7ad6e5 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -73,6 +73,18 @@ cmd generate subcommand_required=#true help="Generate completions, documentation } arg help="The CLI which we're generating completions for" } + cmd completion-init help="Generate a shell init script that auto-completes any usage shebang script on $PATH" { + alias ci + alias init completions-init hide=#true + long_help "Generate a shell init script that auto-completes any usage shebang script on $PATH\n\nSource the output once from your shell rc (e.g. ~/.bashrc) to enable tab-completion for any executable whose first line is a `usage` shebang — no per-script `usage g completion` step required." + flag --usage-bin help="Override the bin used for calling back to usage-cli" default=usage { + long_help "Override the bin used for calling back to usage-cli\n\nYou may need to set this if you have a different bin named \"usage\"" + arg + } + arg help="Shell to generate the init script for" { + choices bash fish zsh + } + } cmd fig help="Generate Fig completion spec for Amazon Q / Fig" { flag "-f --file" help="A usage spec taken in as a file, use \"-\" to read from stdin" { arg diff --git a/docs/cli/completions.md b/docs/cli/completions.md index a9824dda..c2f9728c 100644 --- a/docs/cli/completions.md +++ b/docs/cli/completions.md @@ -1,5 +1,50 @@ # Generating Completion Scripts +## Auto-completion for shebang scripts (bash) + +If you have shell scripts that use the `usage` shebang +(e.g. `#!/usr/bin/env -S usage bash`) and live on `$PATH`, you can enable +tab-completion for all of them at once with a single init line — no per-script +generation required. + +Add this to your `~/.bashrc`: + +```bash +source <(usage g completion-init bash) +``` + +For zsh: + +```bash +# Add this to your ~/.zshrc +source <(usage g completion-init zsh) +``` + +For fish: + +```bash +# Add this to ~/.config/fish/conf.d/usage.fish +usage g completion-init fish | source +``` + +After restarting your shell, `` will work on any script whose first line +is a `usage` shebang. Mechanism per shell: + +- **bash**: registers a `complete -D` default handler that dispatches to + `usage complete-word` for usage shebangs. Source this **after** + bash-completion so the existing default handler is chained to for non-usage + commands. +- **zsh**: registers a `compdef -default-` fallback. Falls back to `_files` + for non-usage commands. +- **fish**: scans `$PATH` once at shell startup (fish has no default-completer + fallback) and registers `complete -c ` per usage-shebang script. + +This is the simplest setup if your CLIs are written as `usage`-shebang scripts. +For `.usage.kdl` specs or binaries with `--usage`, generate per-binary +completion scripts as shown below. + +## Per-binary completion scripts + Usage can generate completion scripts for any shell. Here is an example for bash: ```bash diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index 3e826e48..91afba2f 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -396,6 +396,54 @@ "hidden_aliases": ["complete", "completions"], "examples": [] }, + "completion-init": { + "full_cmd": ["generate", "completion-init"], + "usage": "generate completion-init [--usage-bin ] ", + "subcommands": {}, + "args": [ + { + "name": "SHELL", + "usage": "", + "help": "Shell to generate the init script for", + "help_first_line": "Shell to generate the init script for", + "required": true, + "double_dash": "Optional", + "hide": false, + "choices": { + "choices": ["bash", "fish", "zsh"] + } + } + ], + "flags": [ + { + "name": "usage-bin", + "usage": "--usage-bin ", + "help": "Override the bin used for calling back to usage-cli", + "help_long": "Override the bin used for calling back to usage-cli\n\nYou may need to set this if you have a different bin named \"usage\"", + "help_first_line": "Override the bin used for calling back to usage-cli", + "short": [], + "long": ["usage-bin"], + "hide": false, + "global": false, + "arg": { + "name": "USAGE_BIN", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + }, + "default": ["usage"] + } + ], + "mounts": [], + "hide": false, + "help": "Generate a shell init script that auto-completes any usage shebang script on $PATH", + "help_long": "Generate a shell init script that auto-completes any usage shebang script on $PATH\n\nSource the output once from your shell rc (e.g. ~/.bashrc) to enable tab-completion for any executable whose first line is a `usage` shebang — no per-script `usage g completion` step required.", + "name": "completion-init", + "aliases": ["ci"], + "hidden_aliases": ["init", "completions-init"], + "examples": [] + }, "fig": { "full_cmd": ["generate", "fig"], "usage": "generate fig [FLAGS]", diff --git a/docs/cli/reference/generate.md b/docs/cli/reference/generate.md index 973b08dc..00ea4d0e 100644 --- a/docs/cli/reference/generate.md +++ b/docs/cli/reference/generate.md @@ -11,6 +11,7 @@ Generate completions, documentation, and other artifacts from usage specs ## Subcommands - [`usage generate completion [FLAGS] `](/cli/reference/generate/completion.md) +- [`usage generate completion-init [--usage-bin ] `](/cli/reference/generate/completion-init.md) - [`usage generate fig [FLAGS]`](/cli/reference/generate/fig.md) - [`usage generate json [-f --file ] [--spec ]`](/cli/reference/generate/json.md) - [`usage generate manpage `](/cli/reference/generate/manpage.md) diff --git a/docs/cli/reference/generate/completion-init.md b/docs/cli/reference/generate/completion-init.md new file mode 100644 index 00000000..01ce5c3a --- /dev/null +++ b/docs/cli/reference/generate/completion-init.md @@ -0,0 +1,33 @@ + + +# `usage generate completion-init` + +- **Usage**: `usage generate completion-init [--usage-bin ] ` +- **Aliases**: `ci` +- **Source code**: [`cli/src/cli/generate/completion-init.rs`](https://github.com/jdx/usage/blob/main/cli/src/cli/generate/completion-init.rs) + +Generate a shell init script that auto-completes any usage shebang script on $PATH + +Source the output once from your shell rc (e.g. ~/.bashrc) to enable tab-completion for any executable whose first line is a `usage` shebang — no per-script `usage g completion` step required. + +## Arguments + +### `` + +Shell to generate the init script for + +**Choices:** + +- `bash` +- `fish` +- `zsh` + +## Flags + +### `--usage-bin ` + +Override the bin used for calling back to usage-cli + +You may need to set this if you have a different bin named "usage" + +**Default:** `usage` diff --git a/docs/cli/reference/index.md b/docs/cli/reference/index.md index 3cbb7426..4f0e1c9a 100644 --- a/docs/cli/reference/index.md +++ b/docs/cli/reference/index.md @@ -28,6 +28,7 @@ Outputs a `usage.kdl` spec for this CLI itself - [`usage fish [-h] [--help]