diff --git a/CHANGELOG.md b/CHANGELOG.md index 146bcc6..cd68211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `cx completion ` generates shell completion scripts for bash, zsh, fish, powershell, and elvish. + ## [0.7.1] - 2026-05-15 ### Added diff --git a/Cargo.lock b/Cargo.lock index b5911cd..c03a273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.0" @@ -464,6 +473,7 @@ version = "0.7.1" dependencies = [ "bincode", "clap", + "clap_complete", "dirs", "ignore", "memchr", diff --git a/Cargo.toml b/Cargo.toml index c9102e7..7775f60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ rayon = "1" redb = "3" bincode = "1" dirs = "6.0.0" +clap_complete = "4.6.5" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index c2fb76b..baf5252 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,26 @@ Use `--offset N` to page forward, `--all` to bypass the limit, or `--limit N` to With `--json`, paginated output uses `{total, offset, limit, results: [...]}`. Non-paginated output remains a bare array. +## Shell completion + +`cx completion ` prints a completion script to stdout. Supported shells: `bash`, `zsh`, `fish`, `powershell`, `elvish`. + +```bash +# Bash — current shell +source <(cx completion bash) +# Bash — persist (Linux) +cx completion bash | sudo tee /etc/bash_completion.d/cx > /dev/null + +# Zsh — add to a directory on your $fpath, e.g. +cx completion zsh > "${fpath[1]}/_cx" + +# Fish +cx completion fish > ~/.config/fish/completions/cx.fish + +# PowerShell — add to your profile +cx completion powershell | Out-String | Invoke-Expression +``` + ## How it works On first invocation, cx builds an index by parsing all source files with tree-sitter. The index stores symbols, signatures, and byte ranges for every file; overview derives line ranges from those byte ranges. Subsequent invocations incrementally update only changed files. diff --git a/src/main.rs b/src/main.rs index 8b637e7..6ecdf98 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ mod query; mod language; mod util; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; use std::env; use std::fs; use std::path::{Path, PathBuf}; @@ -105,6 +105,11 @@ enum Commands { }, /// Print the agent skill file to stdout Skill, + /// Generate a shell completion script + Completion { + /// Shell to generate completions for + shell: clap_complete::Shell, + }, /// Manage the index cache Cache { #[command(subcommand)] @@ -218,6 +223,12 @@ fn main() { print!("{}", include_str!("skill.md")); 0 } + Commands::Completion { shell } => { + let mut cmd = Cli::command(); + let bin_name = cmd.get_name().to_string(); + clap_complete::generate(shell, &mut cmd, bin_name, &mut std::io::stdout()); + 0 + } Commands::Cache { action } => { let root = resolve_root(&cli.root, None); let path = index::cache_path_for(&root); diff --git a/tests/integration.rs b/tests/integration.rs index 826ceaf..82614ec 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -926,3 +926,27 @@ fn explicit_root_overrides_path_hint() { // Should fail because B's path isn't under A's root assert!(!out.status.success(), "--root should override path-based discovery"); } + +#[test] +fn completion_emits_script_for_each_shell() { + // Each shell should produce a non-empty script and exit successfully. + // Spot-check a shell-specific marker so we know the right generator ran. + let cases = [ + ("bash", "_cx()"), + ("zsh", "#compdef cx"), + ("fish", "complete -c cx"), + ("powershell", "Register-ArgumentCompleter"), + ]; + for (shell, marker) in cases { + let out = cx().args(["completion", shell]).output().unwrap(); + assert!(out.status.success(), "completion {shell} should succeed: {}", String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(marker), "completion {shell} missing marker {marker:?}: {stdout}"); + } +} + +#[test] +fn completion_rejects_unknown_shell() { + let out = cx().args(["completion", "notashell"]).output().unwrap(); + assert!(!out.status.success(), "unknown shell should fail"); +}