From e6de33124ddb5f35772713a2e5c803ca8943dfca Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 3 May 2026 17:01:10 +0000 Subject: [PATCH 1/7] feat(complete): add completion-init for shebang scripts Adds `usage g completion-init ` (bash/zsh/fish), a one-time init script users source from their shell rc to enable completion for any command on $PATH whose first line is a `usage` shebang. No per-script `usage g completion` generation required. - bash: registers `complete -D` default handler that detects the shebang at completion time and dispatches to `usage complete-word`. Chains to `_completion_loader` for non-usage commands so bash-completion's dynamic loading still works. - zsh: registers `compdef -default-` fallback. Falls back to `_files` for non-usage commands. - fish: scans `$PATH` once at shell startup (no `-default-` equivalent exists) and registers `complete -c ` per usage shebang script. Closes the docs gap reported in #617 where users expected shebang scripts on $PATH to gain completion automatically. Tests: integration tests in shell_completions_integration.rs drive each shell against a staged usage shebang script. The `skip_if_shell_missing` helper now panics under CI=1 to prevent silent skips. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/assets/fig.ts | 21 ++ cli/assets/usage.1 | 28 ++ cli/src/cli/generate/completion_init.rs | 28 ++ cli/src/cli/generate/mod.rs | 3 + cli/tests/shell_completions_integration.rs | 266 +++++++++++++++++- cli/usage.usage.kdl | 12 + docs/cli/completions.md | 30 ++ docs/cli/reference/commands.json | 48 ++++ docs/cli/reference/generate.md | 1 + .../cli/reference/generate/completion-init.md | 31 ++ docs/cli/reference/index.md | 1 + docs/cli/scripts.md | 7 + lib/src/complete/bash.rs | 55 ++++ lib/src/complete/fish.rs | 49 ++++ lib/src/complete/mod.rs | 18 ++ ...lete__bash__tests__complete_bash_init.snap | 39 +++ ...lete__fish__tests__complete_fish_init.snap | 32 +++ ...mplete__zsh__tests__complete_zsh_init.snap | 38 +++ lib/src/complete/zsh.rs | 54 ++++ 19 files changed, 749 insertions(+), 12 deletions(-) create mode 100644 cli/src/cli/generate/completion_init.rs create mode 100644 docs/cli/reference/generate/completion-init.md create mode 100644 lib/src/complete/snapshots/usage__complete__bash__tests__complete_bash_init.snap create mode 100644 lib/src/complete/snapshots/usage__complete__fish__tests__complete_fish_init.snap create mode 100644 lib/src/complete/snapshots/usage__complete__zsh__tests__complete_zsh_init.snap diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index b6aa3334..6f06eacb 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"], + }, + }, { 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..2a35cbce 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 + } + } 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..55d07119 100644 --- a/docs/cli/completions.md +++ b/docs/cli/completions.md @@ -1,5 +1,35 @@ # 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) +``` + +After restarting your shell, `` will work on any script whose first line +is a `usage` shebang. The init script registers a `complete -D` default handler +that detects the shebang at completion time and dispatches to +`usage complete-word`. Non-`usage` commands fall through to bash-completion's +loader if it's installed. + +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. + +::: info +Currently only `bash` is supported for the init flow. zsh / fish use different +completion mechanisms and are tracked as follow-ups. +::: + +## 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..24a7da84 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"] + } + } + ], + "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..09be5226 --- /dev/null +++ b/docs/cli/reference/generate/completion-init.md @@ -0,0 +1,31 @@ + + +# `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` + +## 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]