Skip to main content

agent_profile/cli/
completions.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! The shell completion model. The model exists only for `clap_complete::generate`: it never parses an
8//! argument line, and it mirrors the grammar table on `CommandWord` (scope, flags, positionals), so the
9//! generated scripts cannot drift from the hand dispatcher.
10//!
11//! The bash script needs a workaround. clap_complete 4.6.11 (`aot/shells/bash.rs`) derives the function
12//! name from `bin_name.replace('-', "__")` but the `case` labels from `replace('-', CMD_SEP)`, so for a
13//! binary whose name contains a hyphen the state labels name a function path that is never reached.
14//! `script` rewrites the label back. Remove once upstream names them consistently.
15
16use clap::{Arg, ArgAction, Command, ValueHint};
17use clap_complete::Shell;
18
19use super::{AgentScope, CommandWord, Positional};
20use crate::adapter;
21
22pub(super) const SHELL_NAMES: [&str; 5] = ["bash", "elvish", "fish", "powershell", "zsh"];
23
24/// The exact lower-case spelling only.
25pub(super) fn parse_shell(word: &str) -> Option<Shell> {
26    match word {
27        "bash" => Some(Shell::Bash),
28        "elvish" => Some(Shell::Elvish),
29        "fish" => Some(Shell::Fish),
30        "powershell" => Some(Shell::PowerShell),
31        "zsh" => Some(Shell::Zsh),
32        _ => None,
33    }
34}
35
36/// The completion model; callers build it.
37pub(super) fn model() -> Command {
38    let mut root = Command::new("agent-profile")
39        .version(env!("CARGO_PKG_VERSION"))
40        .disable_help_subcommand(true);
41    for word in CommandWord::ALL {
42        if word.scope() != AgentScope::AgentRequired {
43            root = root.subcommand(command_model(word, false));
44        }
45    }
46    for id in adapter::known_agents() {
47        let mut agent = Command::new(id)
48            .disable_help_subcommand(true)
49            .arg(flag("dry-run"))
50            .arg(flag("verbose"));
51        for word in CommandWord::ALL {
52            if word.scope() != AgentScope::TopLevelOnly {
53                agent = agent.subcommand(command_model(word, true));
54            }
55        }
56        root = root.subcommand(agent);
57    }
58    root
59}
60
61fn command_model(word: CommandWord, with_agent: bool) -> Command {
62    let mut command = Command::new(word.as_str()).disable_help_subcommand(true);
63    if word.accepts_repo() {
64        command = command
65            .arg(Arg::new("repo").long("repo").value_name("path").value_hint(ValueHint::DirPath));
66    }
67    if word.accepts_json() {
68        command = command.arg(flag("json"));
69    }
70    if word.accepts_yes() {
71        command = command.arg(flag("yes"));
72    }
73    match word.positional(with_agent) {
74        Positional::RequiredProfile => {
75            command = command.arg(Arg::new("profile").value_name("profile").required(true));
76        }
77        Positional::OptionalProfile => {
78            command = command.arg(Arg::new("profile").value_name("profile"));
79        }
80        Positional::Shell => {
81            command = command.arg(
82                Arg::new("shell").value_name("shell").required(true).value_parser(SHELL_NAMES),
83            );
84        }
85        Positional::None => {}
86    }
87    command
88}
89
90fn flag(name: &'static str) -> Arg {
91    Arg::new(name).long(name).action(ArgAction::SetTrue)
92}
93
94/// The completion script for `shell`, as the text to print.
95pub(super) fn script(shell: Shell) -> String {
96    let mut model = model();
97    let mut buffer = Vec::new();
98    clap_complete::generate(shell, &mut model, "agent-profile", &mut buffer);
99    let text = String::from_utf8_lossy(&buffer).into_owned();
100    if shell == Shell::Bash {
101        text.replace("agent__subcmd__profile", "agent__profile")
102    } else {
103        text
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use std::collections::BTreeSet;
110    #[cfg(target_os = "linux")]
111    use std::path::Path;
112
113    use super::*;
114
115    fn set<'a>(items: impl IntoIterator<Item = &'a str>) -> BTreeSet<String> {
116        items.into_iter().map(str::to_owned).collect()
117    }
118
119    fn built() -> Command {
120        let mut root = model();
121        root.build();
122        root
123    }
124
125    fn arg_ids(command: &Command) -> BTreeSet<String> {
126        command.get_arguments().map(|a| a.get_id().to_string()).collect()
127    }
128
129    fn sub_names(command: &Command) -> BTreeSet<String> {
130        command.get_subcommands().map(|c| c.get_name().to_owned()).collect()
131    }
132
133    #[test]
134    fn root_subcommands_are_the_top_level_words_and_the_agents() {
135        let root = built();
136        let mut want = set(CommandWord::ALL
137            .iter()
138            .filter(|w| w.scope() != AgentScope::AgentRequired)
139            .map(|w| w.as_str()));
140        want.extend(set(adapter::known_agents()));
141        assert_eq!(sub_names(&root), want);
142    }
143
144    #[test]
145    fn root_arguments_are_help_and_version() {
146        assert_eq!(arg_ids(&built()), set(["help", "version"]));
147    }
148
149    #[test]
150    fn each_agent_offers_the_agent_words_and_the_launch_flags() {
151        let root = built();
152        let want_words = set(CommandWord::ALL
153            .iter()
154            .filter(|w| w.scope() != AgentScope::TopLevelOnly)
155            .map(|w| w.as_str()));
156        for id in adapter::known_agents() {
157            let agent = root.find_subcommand(id).expect("agent subcommand");
158            assert_eq!(sub_names(agent), want_words, "{id}");
159            assert_eq!(arg_ids(agent), set(["dry-run", "verbose", "help"]), "{id}");
160        }
161    }
162
163    #[test]
164    fn each_command_mirrors_the_grammar_table() {
165        let root = built();
166        let mut checked = 0;
167        let mut check = |command: &Command, word: CommandWord, with_agent: bool| {
168            let mut want = set(["help"]);
169            if word.accepts_repo() {
170                want.insert("repo".to_owned());
171                let repo = command.get_arguments().find(|a| a.get_id() == "repo").unwrap();
172                assert_eq!(repo.get_value_hint(), ValueHint::DirPath);
173            }
174            if word.accepts_json() {
175                want.insert("json".to_owned());
176            }
177            if word.accepts_yes() {
178                want.insert("yes".to_owned());
179            }
180            match word.positional(with_agent) {
181                Positional::RequiredProfile | Positional::OptionalProfile => {
182                    want.insert("profile".to_owned());
183                }
184                Positional::Shell => {
185                    want.insert("shell".to_owned());
186                }
187                Positional::None => {}
188            }
189            assert_eq!(arg_ids(command), want, "{}", word.as_str());
190            assert!(sub_names(command).is_empty(), "{}", word.as_str());
191            checked += 1;
192        };
193        for word in CommandWord::ALL {
194            if let Some(command) = root.find_subcommand(word.as_str()) {
195                check(command, word, false);
196            }
197        }
198        for id in adapter::known_agents() {
199            let agent = root.find_subcommand(id).unwrap();
200            for word in CommandWord::ALL {
201                if let Some(command) = agent.find_subcommand(word.as_str()) {
202                    check(command, word, true);
203                }
204            }
205        }
206        assert!(checked > 0);
207    }
208
209    fn walk(command: &Command, found_help: &mut bool) {
210        for sub in command.get_subcommands() {
211            if sub.get_name() == "help" {
212                *found_help = true;
213            }
214            walk(sub, found_help);
215        }
216    }
217
218    #[test]
219    fn no_help_subcommand_anywhere() {
220        let mut found = false;
221        walk(&built(), &mut found);
222        assert!(!found);
223    }
224
225    #[test]
226    fn script_for_each_shell_names_every_agent_and_word() {
227        for shell in [Shell::Bash, Shell::Elvish, Shell::Fish, Shell::PowerShell, Shell::Zsh] {
228            let text = script(shell);
229            assert!(!text.is_empty(), "{shell}");
230            for id in adapter::known_agents() {
231                assert!(text.contains(id), "{shell}: {id}");
232            }
233            for word in CommandWord::ALL {
234                assert!(text.contains(word.as_str()), "{shell}: {}", word.as_str());
235            }
236        }
237    }
238
239    #[test]
240    fn completions_shell_argument_accepts_exactly_the_shell_names() {
241        let mut root = model();
242        root.build();
243        let completions = root.find_subcommand("completions").expect("completions subcommand");
244        let shell = completions
245            .get_arguments()
246            .find(|arg| arg.get_id() == "shell")
247            .expect("shell argument");
248        let values: Vec<String> =
249            shell.get_possible_values().iter().map(|v| v.get_name().to_owned()).collect();
250        assert_eq!(values, SHELL_NAMES);
251    }
252
253    #[test]
254    fn parse_shell_is_exact_lower_case() {
255        let want = [Shell::Bash, Shell::Elvish, Shell::Fish, Shell::PowerShell, Shell::Zsh];
256        for (name, shell) in SHELL_NAMES.iter().zip(want) {
257            assert_eq!(parse_shell(name), Some(shell));
258        }
259        for bad in ["Bash", "BASH", "", "nope"] {
260            assert_eq!(parse_shell(bad), None, "{bad:?}");
261        }
262    }
263
264    #[test]
265    fn bash_script_has_no_mismatched_state_label() {
266        let text = script(Shell::Bash);
267        assert!(!text.contains("agent__subcmd__profile"));
268        assert!(text.contains("agent__profile__subcmd__claude)"));
269    }
270
271    #[cfg(target_os = "linux")]
272    fn complete(script: &Path, words: &[&str]) -> BTreeSet<String> {
273        let out = std::process::Command::new("bash")
274            .args(["--norc", "--noprofile", "-c"])
275            .arg(
276                r#"source "$1"; shift; COMP_WORDS=("$@"); COMP_CWORD=$((${#COMP_WORDS[@]} - 1)); COMP_LINE="$*"; COMP_POINT=${#COMP_LINE}; _agent__profile agent-profile "${COMP_WORDS[COMP_CWORD]}" "${COMP_WORDS[COMP_CWORD - 1]}"; printf "%s\n" "${COMPREPLY[@]}""#,
277            )
278            .arg("bash")
279            .arg(script)
280            .args(words)
281            .output()
282            .unwrap();
283        String::from_utf8_lossy(&out.stdout).lines().map(str::to_owned).collect()
284    }
285
286    #[cfg(target_os = "linux")]
287    #[test]
288    fn bash_script_completes_the_second_and_third_word() {
289        use std::io::Write;
290        let mut file = tempfile::NamedTempFile::new().unwrap();
291        file.write_all(script(Shell::Bash).as_bytes()).unwrap();
292        file.flush().unwrap();
293        let path = file.path();
294
295        let first = complete(path, &["agent-profile", ""]);
296        assert!(first.contains("claude") && first.contains("agents"), "{first:?}");
297
298        let second = complete(path, &["agent-profile", "claude", ""]);
299        assert!(second.contains("create") && second.contains("--dry-run"), "{second:?}");
300        assert!(!second.contains("agents"), "{second:?}");
301
302        let third = complete(path, &["agent-profile", "claude", "create", ""]);
303        assert!(third.contains("--help"), "{third:?}");
304        assert!(!third.contains("--json") && !third.contains("--repo"), "{third:?}");
305    }
306}