diff --git a/.changeset/feat-shell-completion.md b/.changeset/feat-shell-completion.md new file mode 100644 index 00000000..d83be34c --- /dev/null +++ b/.changeset/feat-shell-completion.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add shell completion support via `gws completion `. Supports bash, zsh, fish, powershell, and elvish. diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..876fb557 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -2741,9 +2741,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] diff --git a/src/completion.rs b/src/completion.rs new file mode 100644 index 00000000..1fc411ea --- /dev/null +++ b/src/completion.rs @@ -0,0 +1,509 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::error::GwsError; +use crate::{commands, discovery}; +use clap::Command; + +/// Generates a bridge script for the given shell. +pub fn print_bridge_script(shell: &str) -> Result<(), GwsError> { + match shell.to_lowercase().as_str() { + "bash" => { + println!( + r#" +_gws_complete() {{ + local word="${{COMP_WORDS[COMP_CWORD]}}" + local args=("${{COMP_WORDS[@]:1}}") + + local completions + completions=$(gws __complete "${{args[@]}}") + + local -a suggestions + while IFS= read -r line; do + local name="${{line%%:*}}" + # Filter by current word + if [[ "$name" == "$word"* ]]; then + suggestions+=("$name") + fi + done <<< "$completions" + + COMPREPLY=("${{suggestions[@]}}") +}} +complete -F _gws_complete gws +"# + ); + } + "zsh" => { + println!( + r#" +#compdef gws + +_gws() {{ + local -a completions + local -a words_to_complete + + # Use the current words on the command line, skipping the first one ('gws') + words_to_complete=("${{(@)words[2,$CURRENT]}}") + + # Get completions in 'name:description' format + completions=("${{(@f)$(gws __complete "${{words_to_complete[@]}}")}}") + + if [[ -n "$completions" ]]; then + _describe 'gws commands' completions + fi +}} + +compdef _gws gws +"# + ); + } + "fish" => { + println!( + r#" +function _gws_complete + set -l cmd (commandline -opc) + set -e cmd[1] # remove 'gws' + + # Fish expects 'name\tDescription'. Replace only the first colon. + gws __complete $cmd | string replace -r '^([^:]+):(.*)' '$1\t$2' +end + +complete -c gws -f -a "(_gws_complete)" +"# + ); + } + "powershell" => { + println!( + r#" +Register-ArgumentCompleter -NativeCommandName gws -ScriptBlock {{ + param($wordToComplete, $commandAst, $cursorPosition) + + # Get arguments from the AST, skipping the command itself ('gws'). + $arguments = $commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object {{ $_.Extent.Text }} + + # If the last character on the command line is a space, it means we are completing a new, empty argument. + if ($commandAst.Extent.Text.EndsWith(" ")) {{ + $arguments += "" + }} + + # Pass arguments to the completion command. + gws __complete $arguments | ForEach-Object {{ + # Split only on the first colon to keep the rest of the description intact. + $parts = $_.Split(":", 2) + $name = $parts[0] + $desc = if ($parts.Length -gt 1) {{ $parts[1] }} else {{ "" }} + [System.Management.Automation.CompletionResult]::new($name, $name, 'ParameterValue', $desc) + }} +}} +"# + ); + } + "elvish" => { + println!( + r#" +set edit:completion:arg-completer[gws] = {{ |@args| + # remove 'gws' + set args = $args[1..] + gws __complete $@args | each {{ |line| + local parts = [(str:split : $line &max=2)] + local name = $parts[0] + local desc = '' + if (> (count $parts) 1) {{ + set desc = $parts[1] + }} + edit:complex-candidate $name &display=$name' ('$desc')' + }} +}} +"# + ); + } + other => { + return Err(GwsError::Validation(format!( + "Dynamic completion bridge is not supported for '{}'. Supported: bash, zsh, fish, powershell, elvish", + other + ))); + } + } + Ok(()) +} + +/// Recursively find the subcommand matching the current arguments and print available next tokens. +pub fn handle_dynamic_completion(cli: &Command, args: &[String]) { + let completions = get_completions(cli, args); + // If we have no completions and we are at the top level with no args, + // it might be because get_completions expects at least one arg to suggest anything. + // (Actually get_completions on an empty list should work) + for completion in completions { + println!("{}", completion); + } +} + +fn get_completions(cli: &Command, args: &[String]) -> Vec { + let mut current_cmd = cli; + let mut it = args.iter().peekable(); + let mut completions = Vec::new(); + + // Track seen flags to avoid duplicates + let mut seen_flags = std::collections::HashSet::new(); + + // Skip 'gws' if present + if let Some(&first) = it.peek() { + if first == "gws" { + it.next(); + } + } + + let mut last_arg = None; + let mut active_flag: Option<&clap::Arg> = None; + + // Walking the command tree... + while let Some(arg) = it.next() { + let is_last = it.peek().is_none(); + + if arg.starts_with('-') && arg != "-" && arg != "--" { + // This is a flag. We need to check if it takes a value and consume it + // to avoid misinterpreting it as a subcommand. + let mut arg_def = None; + let mut has_equals = false; + + if arg.starts_with("--") { + let parts: Vec<&str> = arg[2..].splitn(2, '=').collect(); + let name = parts[0]; + has_equals = parts.len() > 1; + arg_def = current_cmd + .get_arguments() + .find(|a| a.get_long() == Some(name)); + if let Some(def) = arg_def { + if let Some(long) = def.get_long() { + seen_flags.insert(long.to_string()); + } + } + } else if arg.len() > 1 { + // Short flag - handle combined flags like -vF + let chars: Vec = arg[1..].chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if let Some(def) = current_cmd + .get_arguments() + .find(|a| a.get_short() == Some(c)) + { + if let Some(short) = def.get_short() { + seen_flags.insert(short.to_string()); + } + if def.get_action().takes_values() { + // Only the last flag in a group can take a value + if i == chars.len() - 1 { + arg_def = Some(def); + } + break; + } + } + } + } + + if let Some(def) = arg_def { + // If it's a flag that takes a value and isn't in `--key=value` form, + // we need to consume the next argument as its value. + if def.get_action().takes_values() && !has_equals { + if is_last { + // The flag is the last argument and it expects a value. + active_flag = Some(def); + last_arg = Some(""); + break; + } + // Since is_last is false, it.peek() is Some, so it.next() is guaranteed to be Some. + let val = it.next().unwrap(); + if it.peek().is_none() { + // This value is the last arg, so we are completing it! + active_flag = Some(def); + last_arg = Some(val.as_str()); + break; + } + } + } + if is_last { + last_arg = Some(arg.as_str()); + break; + } + continue; + } + + if is_last { + // This is the last argument, we'll use it for filtering + last_arg = Some(arg.as_str()); + break; + } + + if let Some(subcmd) = current_cmd.find_subcommand(arg) { + current_cmd = subcmd; + } else { + // Invalid subcommand, stop here + break; + } + } + + let filter = last_arg.unwrap_or(""); + + // If we are completing a flag value, try to suggest allowed values + if let Some(flag) = active_flag { + let val_parser = flag.get_value_parser(); + if let Some(pv) = val_parser.possible_values() { + for val in pv { + if val.get_name().starts_with(filter) { + let help = val + .get_help() + .map(|h| h.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); + completions.push(format!("{}:{}", val.get_name(), help)); + } + } + } + return completions; + } + + // Special case: if the last arg looks like a flag, complete flags + if let Some(arg_filter) = filter.strip_prefix("--") { + for arg in current_cmd.get_arguments() { + if let Some(long) = arg.get_long() { + let can_repeat = matches!( + arg.get_action(), + clap::ArgAction::Append | clap::ArgAction::Count + ); + if (!seen_flags.contains(long) || can_repeat) && long.starts_with(arg_filter) { + let help = arg + .get_help() + .map(|h| h.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); + completions.push(format!("--{}:{}", long, help)); + } + } + } + return completions; + } else if let Some(arg_filter) = filter.strip_prefix('-') { + for arg in current_cmd.get_arguments() { + if let Some(short) = arg.get_short() { + let short_str = short.to_string(); + let can_repeat = matches!( + arg.get_action(), + clap::ArgAction::Append | clap::ArgAction::Count + ); + if (!seen_flags.contains(&short_str) || can_repeat) + && short_str.starts_with(arg_filter) + { + let help = arg + .get_help() + .map(|h| h.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); + completions.push(format!("-{}:{}", short, help)); + } + } + } + return completions; + } + + // Print subcommands with descriptions + for subcmd in current_cmd.get_subcommands() { + if !subcmd.is_hide_set() { + let name = subcmd.get_name(); + // Don't suggest the current filter if it's exactly the subcommand name + // (the shell usually handles this, but it doesn't hurt) + if name.starts_with(filter) && name != filter { + let about = subcmd + .get_about() + .map(|a| a.to_string().replace('\n', " ").trim().to_string()) + .unwrap_or_default(); + completions.push(format!("{}:{}", name, about)); + } + } + } + completions +} + +/// High-level handler for the `__complete` command. +pub async fn handle_complete_command( + args: Vec, + static_cli_builder: fn() -> Command, +) -> Result<(), GwsError> { + if args.is_empty() { + let cmd = static_cli_builder(); + handle_dynamic_completion(&cmd, &[]); + return Ok(()); + } + + let first_arg = &args[0]; + + // Check if the first argument is a static subcommand (not a dynamic service) + let is_service = crate::services::SERVICES + .iter() + .any(|s| s.aliases.contains(&first_arg.as_str())); + if !is_service { + let cmd = static_cli_builder(); + if cmd.find_subcommand(first_arg).is_some() { + handle_dynamic_completion(&cmd, &args); + return Ok(()); + } + } + + match crate::parse_service_and_version(&args, first_arg) { + Ok((api_name, version)) => { + let doc_result = if api_name == "workflow" { + Ok(discovery::RestDescription { + name: "workflow".to_string(), + description: Some("Cross-service productivity workflows".to_string()), + ..Default::default() + }) + } else { + discovery::fetch_discovery_document(&api_name, &version) + .await + .map_err(|e| GwsError::Discovery(format!("{e:#}"))) + }; + + if let Ok(doc) = doc_result { + let cmd = commands::build_cli(&doc); + let sub_args = crate::filter_args_for_subcommand(&args, &api_name); + handle_dynamic_completion(&cmd, &sub_args); + } + } + Err(_) => { + let cmd = static_cli_builder(); + handle_dynamic_completion(&cmd, &args); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{builder::PossibleValue, Arg, Command}; + + fn test_cli() -> Command { + Command::new("gws") + .subcommand( + Command::new("drive") + .about("Google Drive API") + .subcommand( + Command::new("files") + .about("Files resource") + .subcommand(Command::new("list").about("List files")) + .subcommand(Command::new("get").about("Get file metadata")), + ) + .arg( + Arg::new("api-version") + .long("api-version") + .action(clap::ArgAction::Set), + ) + .arg(Arg::new("format").long("format").short('F').value_parser([ + PossibleValue::new("json").help("JSON format"), + PossibleValue::new("table").help("Table format"), + ])), + ) + .subcommand(Command::new("auth").about("Authentication commands")) + } + + #[test] + fn test_get_completions_empty() { + let cli = test_cli(); + let completions = get_completions(&cli, &[]); + assert!(completions.contains(&"drive:Google Drive API".to_string())); + assert!(completions.contains(&"auth:Authentication commands".to_string())); + } + + #[test] + fn test_get_completions_partial_subcommand() { + let cli = test_cli(); + let completions = get_completions(&cli, &["dr".to_string()]); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0], "drive:Google Drive API"); + } + + #[test] + fn test_get_completions_nested() { + let cli = test_cli(); + let completions = get_completions(&cli, &["drive".to_string(), "f".to_string()]); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0], "files:Files resource"); + } + + #[test] + fn test_get_completions_flags_with_values() { + let cli = test_cli(); + // Completing after a flag that takes a value + let completions = get_completions( + &cli, + &[ + "drive".to_string(), + "--api-version".to_string(), + "v3".to_string(), + "f".to_string(), + ], + ); + assert_eq!(completions[0], "files:Files resource"); + } + + #[test] + fn test_get_completions_flags_with_equals() { + let cli = test_cli(); + let completions = get_completions( + &cli, + &[ + "drive".to_string(), + "--api-version=v3".to_string(), + "f".to_string(), + ], + ); + assert_eq!(completions[0], "files:Files resource"); + } + + #[test] + fn test_get_completions_flag_value() { + let cli = test_cli(); + // Input: gws drive --format j -> should suggest json + let completions = get_completions( + &cli, + &["drive".to_string(), "--format".to_string(), "j".to_string()], + ); + assert!(completions.iter().any(|c| c.starts_with("json:"))); + } + + #[test] + fn test_get_completions_flag_last_arg() { + let cli = test_cli(); + // Input: gws drive --format -> should suggest json, table + let completions = get_completions(&cli, &["drive".to_string(), "--format".to_string()]); + assert!(completions.iter().any(|c| c.starts_with("json:"))); + assert!(completions.iter().any(|c| c.starts_with("table:"))); + } + + #[test] + fn test_get_completions_flag_names() { + let cli = test_cli(); + let completions = get_completions(&cli, &["drive".to_string(), "--f".to_string()]); + assert!(completions.iter().any(|c| c.starts_with("--format:"))); + } + + #[test] + fn test_get_completions_skip_seen_flags() { + let cli = test_cli(); + // Once --format is used, it should not be suggested again + let completions = get_completions( + &cli, + &["drive".to_string(), "--format".into(), "json".into(), "--".into()], + ); + assert!(!completions.iter().any(|c| c.starts_with("--format:"))); + + // Once -F is used, it should not be suggested again + let completions = get_completions(&cli, &["drive".to_string(), "-F".into(), "json".into(), "-".into()]); + assert!(!completions.iter().any(|c| c.starts_with("-F:"))); + } +} diff --git a/src/main.rs b/src/main.rs index bd72c642..53274ae7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod auth; pub(crate) mod auth_commands; mod client; mod commands; +mod completion; pub(crate) mod credential_store; mod discovery; mod error; @@ -131,6 +132,29 @@ async fn run() -> Result<(), GwsError> { return generate_skills::handle_generate_skills(&gen_args).await; } + // Handle the `completion` command + if first_arg == "completion" { + let cli = build_static_cli(); + match cli.try_get_matches_from(&args) { + Ok(matches) => { + if let Some(("completion", sub_matches)) = matches.subcommand() { + let shell = sub_matches.get_one::("shell").unwrap(); + return completion::print_bridge_script(shell); + } + unreachable!("Clap parsing succeeded for 'completion' but subcommand not found."); + } + Err(e) => { + e.exit(); + } + } + } + + // Handle the hidden `__complete` command for dynamic shell completion + if first_arg == "__complete" { + let complete_args: Vec = args.iter().skip(2).cloned().collect(); + return completion::handle_complete_command(complete_args, build_static_cli).await; + } + // Handle the `auth` command if first_arg == "auth" { let auth_args: Vec = args.iter().skip(2).cloned().collect(); @@ -414,6 +438,65 @@ fn resolve_method_from_matches<'a>( ))) } +fn build_static_cli() -> clap::Command { + use clap::{Arg, Command}; + + let mut cmd = Command::new("gws") + .version(env!("CARGO_PKG_VERSION")) + .about("Google Workspace CLI") + .subcommand( + Command::new("auth") + .about("Manage authentication") + .subcommand(Command::new("login").about("Log in and save credentials")) + .subcommand(Command::new("logout").about("Log out and clear credentials")) + .subcommand(Command::new("setup").about("Run guided setup for GCP project")) + .subcommand(Command::new("status").about("Show current auth status")) + .subcommand(Command::new("export").about("Export credentials to stdout")), + ) + .subcommand( + Command::new("schema") + .about("Introspect API method schemas") + .arg( + Arg::new("path") + .required(true) + .help("API path (e.g. drive.files.list)"), + ) + .arg( + Arg::new("resolve-refs") + .long("resolve-refs") + .action(clap::ArgAction::SetTrue), + ), + ) + .subcommand( + Command::new("generate-skills") + .about("Generate SKILL.md files for AI agents") + .arg(Arg::new("output-dir").long("output-dir").num_args(1)), + ) + .subcommand( + Command::new("completion") + .about("Generate shell completion scripts") + .arg(Arg::new("shell").required(true).value_parser([ + "bash", + "zsh", + "fish", + "powershell", + "elvish", + ])), + ); + + // Add service aliases as subcommands so they show up in top-level completion + for entry in services::SERVICES { + for alias in entry.aliases { + if cmd.get_subcommands().any(|s| s.get_name() == *alias) { + continue; + } + cmd = cmd.subcommand(Command::new(*alias).about(entry.description)); + } + } + + cmd +} + fn print_usage() { println!("gws — Google Workspace CLI"); println!();