1use std::ffi::{OsStr, OsString};
11use std::io::{self, BufRead, IsTerminal, Write};
12use std::path::PathBuf;
13
14use clap::{Parser, Subcommand};
15use clap_complete::Shell;
16
17use crate::adapter::{self, PlanContext, ProfilePresence};
18use crate::config::{self, AppRoot, Config};
19use crate::doctor;
20use crate::error::{Error, Result};
21use crate::exe;
22use crate::json;
23use crate::launch::{self, LaunchOutcome};
24use crate::lifecycle;
25use crate::name::{AgentId, Platform, ProfileName, RESERVED_WORDS, is_reserved_word};
26use crate::output::{self, ReportMode};
27use crate::repo::{self, Discovery};
28use crate::repositories;
29use crate::resolve;
30
31mod completions;
32use completions::SHELL_NAMES;
33
34const LAUNCH_USAGE: &str = "\
35Usage: agent-profile <agent> <profile> [--dry-run] [--verbose] [-- <agent args>...]
36
37Launch <agent> with <profile>. Everything after `--` is passed to the agent unchanged.
38
39Options:
40 --dry-run Show what would be launched, without launching or creating anything
41 --verbose Print the launch report to stderr before launching
42 -h, --help Print this help
43 -V, --version Print the version
44";
45
46const COMMANDS_HELP: &str = "\
47Repository commands:
48 agent-profile <agent> current [--repo <path>]
49 agent-profile <agent> resolve [--repo <path>] [--json]
50 agent-profile [<agent>] status [--repo <path>] [--json]
51 agent-profile [<agent>] link <profile> [--repo <path>]
52 agent-profile [<agent>] unlink [--repo <path>]
53 agent-profile repositories [--json]
54 agent-profile doctor [--repo <path>] [--json]
55 agent-profile <agent> doctor [<profile>] [--repo <path>] [--json]
56
57Profile commands:
58 agent-profile <agent> create <profile>
59 agent-profile <agent> delete <profile> [--yes]
60 agent-profile [<agent>] list [--json]
61 agent-profile agents [--json]
62 agent-profile profiles [--json]
63
64Shell completion:
65 agent-profile completions <shell>";
66
67#[derive(Parser)]
69#[command(
70 name = "agent-profile",
71 version,
72 disable_help_subcommand = true,
73 arg_required_else_help = true,
74 override_usage = "agent-profile <agent> <profile> [--dry-run] [--verbose] [-- <agent args>...]\n \
75 agent-profile <command>",
76 after_help = COMMANDS_HELP
77)]
78struct Cli {
79 #[command(subcommand)]
80 command: Command,
81}
82
83#[derive(Subcommand)]
86enum Command {
87 #[command(external_subcommand)]
88 Agent(Vec<OsString>),
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum CommandWord {
94 Current,
95 Resolve,
96 Status,
97 Link,
98 Unlink,
99 Create,
100 Delete,
101 List,
102 Agents,
103 Profiles,
104 Repositories,
105 Doctor,
106 Completions,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum AgentScope {
112 TopLevelOnly,
113 AgentRequired,
114 Either,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum Positional {
120 None,
121 RequiredProfile,
122 OptionalProfile,
123 Shell,
124}
125
126impl CommandWord {
127 fn parse(word: &str) -> Option<CommandWord> {
129 Some(match word {
130 "current" => CommandWord::Current,
131 "resolve" => CommandWord::Resolve,
132 "status" => CommandWord::Status,
133 "link" => CommandWord::Link,
134 "unlink" => CommandWord::Unlink,
135 "create" => CommandWord::Create,
136 "delete" => CommandWord::Delete,
137 "list" => CommandWord::List,
138 "agents" => CommandWord::Agents,
139 "profiles" => CommandWord::Profiles,
140 "repositories" => CommandWord::Repositories,
141 "doctor" => CommandWord::Doctor,
142 "completions" => CommandWord::Completions,
143 _ => return None,
144 })
145 }
146
147 fn as_str(self) -> &'static str {
148 match self {
149 CommandWord::Current => "current",
150 CommandWord::Resolve => "resolve",
151 CommandWord::Status => "status",
152 CommandWord::Link => "link",
153 CommandWord::Unlink => "unlink",
154 CommandWord::Create => "create",
155 CommandWord::Delete => "delete",
156 CommandWord::List => "list",
157 CommandWord::Agents => "agents",
158 CommandWord::Profiles => "profiles",
159 CommandWord::Repositories => "repositories",
160 CommandWord::Doctor => "doctor",
161 CommandWord::Completions => "completions",
162 }
163 }
164
165 const ALL: [CommandWord; 13] = [
166 CommandWord::Current,
167 CommandWord::Resolve,
168 CommandWord::Status,
169 CommandWord::Link,
170 CommandWord::Unlink,
171 CommandWord::Create,
172 CommandWord::Delete,
173 CommandWord::List,
174 CommandWord::Agents,
175 CommandWord::Profiles,
176 CommandWord::Repositories,
177 CommandWord::Doctor,
178 CommandWord::Completions,
179 ];
180
181 fn scope(self) -> AgentScope {
182 match self {
183 CommandWord::Current
184 | CommandWord::Resolve
185 | CommandWord::Create
186 | CommandWord::Delete => AgentScope::AgentRequired,
187 CommandWord::Agents
188 | CommandWord::Profiles
189 | CommandWord::Repositories
190 | CommandWord::Completions => AgentScope::TopLevelOnly,
191 CommandWord::Status
192 | CommandWord::Link
193 | CommandWord::Unlink
194 | CommandWord::List
195 | CommandWord::Doctor => AgentScope::Either,
196 }
197 }
198
199 fn accepts_repo(self) -> bool {
200 match self {
201 CommandWord::Current
202 | CommandWord::Resolve
203 | CommandWord::Status
204 | CommandWord::Link
205 | CommandWord::Unlink
206 | CommandWord::Doctor => true,
207 CommandWord::Create
208 | CommandWord::Delete
209 | CommandWord::List
210 | CommandWord::Agents
211 | CommandWord::Profiles
212 | CommandWord::Repositories
213 | CommandWord::Completions => false,
214 }
215 }
216
217 fn accepts_json(self) -> bool {
218 match self {
219 CommandWord::Resolve
220 | CommandWord::Status
221 | CommandWord::List
222 | CommandWord::Agents
223 | CommandWord::Profiles
224 | CommandWord::Repositories
225 | CommandWord::Doctor => true,
226 CommandWord::Current
227 | CommandWord::Link
228 | CommandWord::Unlink
229 | CommandWord::Create
230 | CommandWord::Delete
231 | CommandWord::Completions => false,
232 }
233 }
234
235 fn accepts_yes(self) -> bool {
236 match self {
237 CommandWord::Delete => true,
238 CommandWord::Current
239 | CommandWord::Resolve
240 | CommandWord::Status
241 | CommandWord::Link
242 | CommandWord::Unlink
243 | CommandWord::Create
244 | CommandWord::List
245 | CommandWord::Agents
246 | CommandWord::Profiles
247 | CommandWord::Repositories
248 | CommandWord::Doctor
249 | CommandWord::Completions => false,
250 }
251 }
252
253 fn positional(self, with_agent: bool) -> Positional {
254 match self {
255 CommandWord::Link | CommandWord::Create | CommandWord::Delete => {
256 Positional::RequiredProfile
257 }
258 CommandWord::Completions => Positional::Shell,
259 CommandWord::Doctor if with_agent => Positional::OptionalProfile,
260 CommandWord::Doctor
261 | CommandWord::Current
262 | CommandWord::Resolve
263 | CommandWord::Status
264 | CommandWord::Unlink
265 | CommandWord::List
266 | CommandWord::Agents
267 | CommandWord::Profiles
268 | CommandWord::Repositories => Positional::None,
269 }
270 }
271
272 fn usage(self) -> &'static str {
274 match self {
275 CommandWord::Current => {
276 "Usage: agent-profile <agent> current [--repo <path>]\n\
277 Print the profile agent-profile would select for <agent> here.\n\n \
278 --repo <path> Use the repository at <path> instead of the current directory\n"
279 }
280 CommandWord::Resolve => {
281 "Usage: agent-profile <agent> resolve [--repo <path>] [--json]\n\
282 Show the profile, where it comes from, and the repository.\n\n \
283 --repo <path> Use the repository at <path> instead of the current directory\n \
284 --json Print a JSON document instead of the report\n"
285 }
286 CommandWord::Status => {
287 "Usage: agent-profile [<agent>] status [--repo <path>] [--json]\n\
288 Show the repository, its mappings, the default profile and what each agent resolves to.\n\n \
289 --repo <path> Use the repository at <path> instead of the current directory\n \
290 --json Print a JSON document instead of the report\n"
291 }
292 CommandWord::Link => {
293 "Usage: agent-profile [<agent>] link <profile> [--repo <path>]\n\
294 Map this repository (or only <agent> in it) to <profile>.\n\n \
295 --repo <path> Use the repository at <path> instead of the current directory\n"
296 }
297 CommandWord::Unlink => {
298 "Usage: agent-profile [<agent>] unlink [--repo <path>]\n\
299 Remove this repository's mapping (or only <agent>'s). With --repo, removes the mapping stored \
300 for that path.\n\n \
301 --repo <path> Use the repository at <path> instead of the current directory\n"
302 }
303 CommandWord::Create => {
304 "Usage: agent-profile <agent> create <profile>\n\
305 Create <agent>'s profile directory, without launching <agent>.\n"
306 }
307 CommandWord::Delete => {
308 "Usage: agent-profile <agent> delete <profile> [--yes]\n\
309 Delete <agent>'s profile directory.\n\n \
310 --yes Delete without asking\n"
311 }
312 CommandWord::List => {
313 "Usage: agent-profile [<agent>] list [--json]\n\
314 List the profiles that exist; with <agent>, only <agent>'s profile names.\n\n \
315 --json Print a JSON document instead of the report\n"
316 }
317 CommandWord::Agents => {
318 "Usage: agent-profile agents [--json]\n\
319 Show every known agent with its support level and full capability matrix.\n\n \
320 --json Print a JSON document instead of the report\n"
321 }
322 CommandWord::Profiles => {
323 "Usage: agent-profile profiles [--json]\n\
324 List the profiles that exist, with the agents that have one.\n\n \
325 --json Print a JSON document instead of the report\n"
326 }
327 CommandWord::Repositories => {
328 "Usage: agent-profile repositories [--json]\n\
329 Show every stored repository mapping and whether it still applies.\n\n \
330 --json Print a JSON document instead of the report\n"
331 }
332 CommandWord::Doctor => {
333 "Usage: agent-profile doctor [--repo <path>] [--json]\n \
334 agent-profile <agent> doctor [<profile>] [--repo <path>] [--json]\n\
335 Check this machine, its configuration, the repository mappings and the agents; with <agent>, \
336 also <agent> and its profile here.\n\n \
337 --repo <path> Use the repository at <path> instead of the current directory\n \
338 --json Print a JSON document instead of the report\n"
339 }
340 CommandWord::Completions => {
341 "Usage: agent-profile completions <shell>\n\
342 Print a shell completion script to stdout. <shell> is bash, elvish, fish, powershell or zsh.\n"
343 }
344 }
345 }
346}
347
348pub fn run(args: impl IntoIterator<Item = OsString>) -> i32 {
350 let args: Vec<OsString> = args.into_iter().collect();
351 if let Some(command) = args.get(1).and_then(|word| word.to_str()).and_then(CommandWord::parse) {
354 let rest = args[2..].to_vec();
355 return match split_top_level(command, rest).and_then(execute) {
356 Ok(code) => code,
357 Err(error) => report(error),
358 };
359 }
360 let cli = match Cli::try_parse_from(args) {
361 Ok(cli) => cli,
362 Err(error) => {
363 let _ = error.print();
364 return error.exit_code();
365 }
366 };
367 let Command::Agent(argv) = cli.command;
368 let known = adapter::known_agents();
369 match split(argv, &known)
370 .map_err(|error| with_unknown_configured(error, None, &known))
371 .and_then(execute)
372 {
373 Ok(code) => code,
374 Err(error) => report(error),
375 }
376}
377
378fn report(error: Error) -> i32 {
379 let _ = writeln!(io::stderr(), "agent-profile: error: {error}");
380 error.exit_code()
381}
382
383#[derive(Debug, PartialEq, Eq)]
385enum Invocation {
386 Help,
387 CommandHelp(CommandWord),
388 Version,
389 Completions(Shell),
390 Launch {
391 agent: String,
392 profile: Option<String>,
393 dry_run: bool,
394 verbose: bool,
395 opaque: Vec<OsString>,
396 },
397 Command {
398 agent: Option<String>,
399 command: CommandWord,
400 profile: Option<String>,
401 repo: Option<OsString>,
402 yes: bool,
403 json: bool,
404 },
405}
406
407#[derive(Debug)]
409enum Token {
410 Repo(OsString),
411 Option(OsString),
412 Bare(OsString),
413}
414
415fn usage(message: impl Into<String>) -> Error {
416 Error::Usage { message: message.into() }
417}
418
419fn cut(args: Vec<OsString>) -> (Vec<OsString>, Option<Vec<OsString>>) {
421 match args.iter().position(|arg| arg == "--") {
422 Some(index) => (args[..index].to_vec(), Some(args[index + 1..].to_vec())),
423 None => (args, None),
424 }
425}
426
427fn bind(pre: Vec<OsString>) -> Result<Vec<Token>> {
429 let mut tokens = Vec::with_capacity(pre.len());
430 let mut pre = pre.into_iter();
431 while let Some(arg) = pre.next() {
432 if arg == "--repo" {
433 let value = pre.next().ok_or_else(|| usage("`--repo` needs a path"))?;
434 tokens.push(Token::Repo(value));
435 } else if let Some(value) = repo_value(&arg) {
436 tokens.push(Token::Repo(value));
437 } else if arg.as_encoded_bytes().first() == Some(&b'-') {
438 tokens.push(Token::Option(arg));
439 } else {
440 tokens.push(Token::Bare(arg));
441 }
442 }
443 Ok(tokens)
444}
445
446const REPO_EQUALS: &str = "--repo=";
447
448#[cfg(unix)]
450fn repo_value(arg: &OsStr) -> Option<OsString> {
451 use std::os::unix::ffi::OsStrExt;
452 let bytes = arg.as_bytes();
453 bytes
454 .starts_with(REPO_EQUALS.as_bytes())
455 .then(|| OsStr::from_bytes(&bytes[REPO_EQUALS.len()..]).to_owned())
456}
457
458#[cfg(windows)]
460fn repo_value(arg: &OsStr) -> Option<OsString> {
461 use std::os::windows::ffi::{OsStrExt, OsStringExt};
462 let units: Vec<u16> = arg.encode_wide().collect();
463 let prefix: Vec<u16> = OsStr::new(REPO_EQUALS).encode_wide().collect();
464 units.starts_with(&prefix).then(|| OsString::from_wide(&units[prefix.len()..]))
465}
466
467fn options(tokens: &[Token]) -> impl Iterator<Item = &OsString> {
468 tokens.iter().filter_map(|token| match token {
469 Token::Option(option) => Some(option),
470 _ => None,
471 })
472}
473
474fn bare_words(tokens: &[Token]) -> Vec<&OsString> {
475 tokens
476 .iter()
477 .filter_map(|token| match token {
478 Token::Bare(word) => Some(word),
479 _ => None,
480 })
481 .collect()
482}
483
484fn has_help(tokens: &[Token]) -> bool {
485 options(tokens).any(|option| option == "-h" || option == "--help")
486}
487
488fn has_version(tokens: &[Token]) -> bool {
489 options(tokens).any(|option| option == "-V" || option == "--version")
490}
491
492fn option_name(option: &OsStr) -> String {
494 let shown = option.to_string_lossy();
495 shown.split_once('=').map_or(&*shown, |(name, _)| name).to_owned()
496}
497
498fn split_top_level(command: CommandWord, rest: Vec<OsString>) -> Result<Invocation> {
500 let (pre, opaque) = cut(rest);
501 let tokens = bind(pre)?;
502 if has_help(&tokens) {
503 return Ok(Invocation::CommandHelp(command));
504 }
505 if has_version(&tokens) {
506 return Ok(Invocation::Version);
507 }
508 if command.scope() == AgentScope::AgentRequired {
509 let name = command.as_str();
510 let profile =
511 if command.positional(true) == Positional::RequiredProfile { " <profile>" } else { "" };
512 return Err(usage(format!(
513 "`{name}` needs an agent: agent-profile <agent> {name}{profile}"
514 )));
515 }
516 let bare = bare_words(&tokens);
517 validate_command(None, command, &tokens, &bare, opaque.is_some())
518}
519
520fn split(argv: Vec<OsString>, known: &[&str]) -> Result<Invocation> {
522 let mut argv = argv.into_iter();
523 let agent_word = argv.next().unwrap_or_default();
524 let (pre, opaque) = cut(argv.collect());
525 let tokens = bind(pre)?;
526 let bare = bare_words(&tokens);
527 let first = bare.first().and_then(|word| word.to_str());
528
529 if has_help(&tokens) {
531 return Ok(match first.and_then(CommandWord::parse) {
532 Some(command) => Invocation::CommandHelp(command),
533 None => Invocation::Help,
534 });
535 }
536 if has_version(&tokens) {
537 return Ok(Invocation::Version);
538 }
539 let agent = match agent_word.to_str() {
541 Some(agent) if known.contains(&agent) => agent.to_owned(),
542 _ => {
543 return Err(Error::UnknownAgent {
544 agent: agent_word.to_string_lossy().into_owned(),
545 known: known.iter().map(|agent| (*agent).to_owned()).collect(),
546 unknown_configured: Vec::new(),
547 });
548 }
549 };
550 if let Some(word) = first
552 && is_reserved_word(word)
553 {
554 if let Some(command) = CommandWord::parse(word) {
555 return validate_command(Some(agent), command, &tokens, &bare[1..], opaque.is_some());
556 }
557 if RESERVED_WORDS.contains(&word) {
558 return Err(Error::NotYetImplemented { command: format!("{agent} {word}") });
559 }
560 return Err(usage(format!(
561 "command words are lower case: `{}`",
562 word.to_ascii_lowercase()
563 )));
564 }
565 let launch_options: Vec<OsString> = tokens
567 .iter()
568 .filter_map(|token| match token {
569 Token::Option(option) => Some(option.clone()),
570 Token::Repo(_) => Some(OsString::from("--repo")),
571 Token::Bare(_) => None,
572 })
573 .collect();
574 if let Some(bad) = launch_options
575 .iter()
576 .find(|arg| !matches!(arg.to_str(), Some("--dry-run" | "--verbose" | "--json")))
577 {
578 return Err(usage(format!(
579 "unknown option {:?}; agent arguments must follow `--`",
580 option_name(bad)
581 )));
582 }
583 if bare.len() > 1 {
584 return Err(usage("agent arguments must follow `--`"));
585 }
586 if launch_options.iter().any(|arg| arg == "--json") {
587 return Err(Error::NotYetImplemented { command: "--json".to_owned() });
588 }
589 let profile = match bare.first() {
590 Some(word) => Some(
591 word.to_str().ok_or_else(|| usage("the profile name is not valid UTF-8"))?.to_owned(),
592 ),
593 None => None,
594 };
595 Ok(Invocation::Launch {
596 agent,
597 profile,
598 dry_run: launch_options.iter().any(|arg| arg == "--dry-run"),
599 verbose: launch_options.iter().any(|arg| arg == "--verbose"),
600 opaque: opaque.unwrap_or_default(),
601 })
602}
603
604fn validate_command(
606 agent: Option<String>,
607 command: CommandWord,
608 tokens: &[Token],
609 bare: &[&OsString],
610 has_cut: bool,
611) -> Result<Invocation> {
612 let name = command.as_str();
613 if has_cut {
614 return Err(usage(format!("`{name}` takes no agent arguments; remove `--`")));
615 }
616 if command.scope() == AgentScope::TopLevelOnly && agent.is_some() {
618 return Err(usage(format!("`{name}` takes no agent: agent-profile {name}")));
619 }
620 let repos: Vec<&OsString> = tokens
621 .iter()
622 .filter_map(|token| match token {
623 Token::Repo(repo) => Some(repo),
624 _ => None,
625 })
626 .collect();
627 if !command.accepts_repo() && !repos.is_empty() {
629 return Err(usage(format!("unknown option \"--repo\" for `{name}`")));
630 }
631 if repos.len() > 1 {
632 return Err(usage("`--repo` may be given only once"));
633 }
634 if repos.first().is_some_and(|repo| repo.is_empty()) {
635 return Err(usage("`--repo` needs a non-empty path"));
636 }
637 let json_count = options(tokens).filter(|option| **option == "--json").count();
638 if matches!(command, CommandWord::Create | CommandWord::Delete) && json_count > 0 {
639 return Err(Error::NotYetImplemented { command: "--json".to_owned() });
640 }
641 let json_accepted = command.accepts_json();
642 if json_accepted && json_count > 1 {
643 return Err(usage("`--json` may be given only once"));
644 }
645 let json = json_accepted && json_count == 1;
646 let yes = command.accepts_yes() && options(tokens).any(|option| option == "--yes");
649 if let Some(option) = options(tokens)
650 .find(|option| !(yes && **option == "--yes") && !(json && **option == "--json"))
651 {
652 return Err(usage(format!("unknown option {:?} for `{name}`", option_name(option))));
653 }
654 let positional = command.positional(agent.is_some());
656 let allowed = usize::from(positional != Positional::None);
657 if command == CommandWord::Link && bare.is_empty() {
658 return Err(usage("`link` needs a profile: agent-profile [<agent>] link <profile>"));
659 }
660 if bare.len() > allowed {
661 return Err(usage(match positional {
662 Positional::RequiredProfile | Positional::OptionalProfile => {
663 format!("`{name}` takes one profile")
664 }
665 Positional::Shell => format!("`{name}` takes one shell"),
666 Positional::None => format!("`{name}` takes no arguments"),
667 }));
668 }
669 if matches!(command, CommandWord::Create | CommandWord::Delete) && bare.is_empty() {
670 return Err(usage(format!(
671 "`{name}` needs a profile: agent-profile <agent> {name} <profile>"
672 )));
673 }
674 if positional == Positional::Shell {
675 let Some(word) = bare.first() else {
676 return Err(usage(format!(
677 "`{name}` needs a shell: agent-profile {name} <{}>",
678 SHELL_NAMES.join("|")
679 )));
680 };
681 let word = word.to_string_lossy();
682 return completions::parse_shell(&word).map(Invocation::Completions).ok_or_else(|| {
683 usage(format!(
684 "unknown shell `{word}` (shells: {})",
685 SHELL_NAMES.iter().map(|shell| format!("`{shell}`")).collect::<Vec<_>>().join(", ")
686 ))
687 });
688 }
689 let profile = match bare.first() {
690 Some(word) => Some(
691 word.to_str().ok_or_else(|| usage("the profile name is not valid UTF-8"))?.to_owned(),
692 ),
693 None => None,
694 };
695 Ok(Invocation::Command {
696 agent,
697 command,
698 profile,
699 repo: repos.first().map(|repo| (*repo).clone()),
700 yes,
701 json,
702 })
703}
704
705fn execute(invocation: Invocation) -> Result<i32> {
706 match invocation {
707 Invocation::Help => {
708 write_out(LAUNCH_USAGE)?;
709 Ok(0)
710 }
711 Invocation::CommandHelp(command) => {
712 write_out(command.usage())?;
713 Ok(0)
714 }
715 Invocation::Version => {
716 write_out(&format!("agent-profile {}\n", env!("CARGO_PKG_VERSION")))?;
717 Ok(0)
718 }
719 Invocation::Completions(shell) => {
720 write_out(&completions::script(shell))?;
721 Ok(0)
722 }
723 Invocation::Launch { agent, profile, dry_run, verbose, opaque } => {
724 run_launch(agent, profile, dry_run, verbose, opaque)
725 }
726 Invocation::Command { agent, command, profile, repo, yes, json } => {
727 run_command(agent, command, profile, repo, yes, json)
728 }
729 }
730}
731
732#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734enum LaunchDiscovery {
735 Required,
737 ReportOnly,
739 Skipped,
741}
742
743fn launch_discovery(explicit: bool, dry_run: bool, verbose: bool) -> LaunchDiscovery {
744 match (explicit, dry_run || verbose) {
745 (false, _) => LaunchDiscovery::Required,
746 (true, true) => LaunchDiscovery::ReportOnly,
747 (true, false) => LaunchDiscovery::Skipped,
748 }
749}
750
751fn parse_profile(name: String) -> Result<ProfileName> {
752 ProfileName::parse(&name, Platform::host())
753 .map_err(|reason| Error::InvalidProfileName { name, reason })
754}
755
756fn base_dir(repo: Option<&OsStr>) -> Result<PathBuf> {
760 match repo {
761 Some(repo) if std::path::Path::new(repo).is_absolute() => Ok(PathBuf::new()),
762 _ => current_dir(),
763 }
764}
765
766fn current_dir() -> Result<PathBuf> {
767 std::env::current_dir().map_err(|error| Error::Repository {
768 path: PathBuf::from("."),
769 reason: format!("cannot resolve the directory: {error}"),
770 })
771}
772
773fn no_profile(agent: &AgentId, root: &AppRoot, discovery: &Discovery) -> Error {
774 Error::NoProfile {
775 agent: agent.to_string(),
776 config_file: root.config_path(),
777 in_repository: matches!(discovery, Discovery::Repository(_)),
778 }
779}
780
781fn run_launch(
782 agent: String,
783 profile: Option<String>,
784 dry_run: bool,
785 verbose: bool,
786 opaque: Vec<OsString>,
787) -> Result<i32> {
788 let known = adapter::known_agents();
790 let profile = profile.map(parse_profile).transpose()?;
791 let root = AppRoot::resolve()?;
792 let config = Config::load(&root)?;
793 let agent = AgentId::parse(&agent).expect("known agents are valid agent ids");
794 let discovery = match launch_discovery(profile.is_some(), dry_run, verbose) {
795 LaunchDiscovery::Required => repo::discover(¤t_dir()?)?,
796 LaunchDiscovery::ReportOnly => std::env::current_dir()
797 .ok()
798 .and_then(|cwd| repo::discover(&cwd).ok())
799 .unwrap_or(Discovery::NotInRepository),
800 LaunchDiscovery::Skipped => Discovery::NotInRepository,
801 };
802 let resolution = resolve::resolve(agent.clone(), profile, &config, &discovery);
803 let Some(profile) = resolution.profile.as_ref() else {
804 return Err(no_profile(&agent, &root, &discovery));
805 };
806
807 let adapter = adapter::lookup(agent.as_str()).expect("known agents have an adapter");
809 adapter::check_conflicts(adapter.metadata(), &opaque)?;
811 let path_var = std::env::var_os("PATH");
813 let ctx = PlanContext {
814 profile,
815 root: &root,
816 config: &config,
817 args: &opaque,
818 path_var: path_var.as_deref(),
819 };
820 let planned = adapter
821 .plan(&ctx)
822 .map_err(|error| with_unknown_configured(error, Some(&config), &known))?;
823
824 let metadata = adapter.metadata();
826 if dry_run {
827 let lines = output::report_lines(&planned, &resolution, ReportMode::DryRun, metadata);
828 write_out(&lines.iter().map(|line| format!("{line}\n")).collect::<String>())?;
829 return Ok(0);
830 }
831
832 adapter.initialize(&planned)?;
834 if verbose {
835 let mut stderr = io::stderr();
836 for line in &output::report_lines(&planned, &resolution, ReportMode::Verbose, metadata) {
837 let _ = writeln!(stderr, "agent-profile: {line}");
838 }
839 let _ = stderr.flush();
840 } else if let Some(hedge) = output::support_hedge(metadata) {
841 let mut stderr = io::stderr();
844 let _ = writeln!(stderr, "agent-profile: {hedge}");
845 let _ = stderr.flush();
846 }
847 match launch::launch(&planned.plan, verbose)? {
848 LaunchOutcome::Exited(code) => Ok(code),
849 LaunchOutcome::ReplacedProcess => Ok(0),
850 }
851}
852
853fn run_command(
855 agent: Option<String>,
856 command: CommandWord,
857 profile: Option<String>,
858 repo: Option<OsString>,
859 yes: bool,
860 json: bool,
861) -> Result<i32> {
862 if command == CommandWord::Doctor {
863 return run_doctor(agent, profile, repo, json);
864 }
865 let profile = profile.map(parse_profile).transpose()?;
866 let root = AppRoot::resolve()?;
867 let config = Config::load(&root)?;
868 if command == CommandWord::Agents {
869 return run_agents(&root, &config, json);
870 }
871 if command == CommandWord::Repositories {
872 return run_repositories(&root, &config, json);
873 }
874 let agent =
875 agent.map(|agent| AgentId::parse(&agent).expect("known agents are valid agent ids"));
876 if matches!(command, CommandWord::List | CommandWord::Profiles) {
877 return run_profiles(&root, agent.as_ref(), json);
878 }
879 if command == CommandWord::Create {
884 return run_create(
885 &root,
886 agent.as_ref().expect("`create` has an agent"),
887 profile.as_ref().expect("`create` has a profile"),
888 );
889 }
890 if command == CommandWord::Delete {
891 return run_delete(
892 &root,
893 &config,
894 agent.as_ref().expect("`delete` has an agent"),
895 profile.as_ref().expect("`delete` has a profile"),
896 yes,
897 );
898 }
899 let cwd = base_dir(repo.as_deref())?;
900 if command == CommandWord::Unlink {
901 return run_unlink(&root, &config, agent.as_ref(), &cwd, repo.as_deref());
902 }
903 let start = match &repo {
904 Some(repo) => cwd.join(repo),
905 None => cwd,
906 };
907 let discovery = repo::discover(&start)?;
908 let lines = match command {
909 CommandWord::Current => {
910 let agent = agent.expect("`current` has an agent");
911 let resolution = resolve::resolve(agent.clone(), None, &config, &discovery);
912 let Some(profile) = resolution.profile else {
913 return Err(no_profile(&agent, &root, &discovery));
914 };
915 vec![profile.to_string()]
916 }
917 CommandWord::Resolve => {
918 let agent = agent.expect("`resolve` has an agent");
919 let resolution = resolve::resolve(agent.clone(), None, &config, &discovery);
920 if json {
921 write_out(&json::render(&json::resolve(&resolution)))?;
922 } else {
923 write_lines(&output::resolve_lines(&resolution))?;
924 }
925 if resolution.profile.is_none() {
926 return Err(no_profile(&agent, &root, &discovery));
927 }
928 return Ok(0);
929 }
930 CommandWord::Status => {
931 let agents: Vec<AgentId> = match &agent {
932 Some(agent) => vec![agent.clone()],
933 None => adapter::known_agents()
934 .into_iter()
935 .map(|id| AgentId::parse(id).expect("known agents are valid agent ids"))
936 .collect(),
937 };
938 let rows: Vec<_> = agents
939 .into_iter()
940 .map(|id| {
941 let resolution = resolve::resolve(id, None, &config, &discovery);
942 let presence = match (&agent, &resolution.profile) {
943 (Some(agent), Some(profile)) => {
944 Some(lifecycle::presence(&root, agent, profile))
945 }
946 _ => None,
947 };
948 (resolution, presence)
949 })
950 .collect();
951 if json {
952 write_out(&json::render(&json::status(
953 agent.as_ref(),
954 &discovery,
955 &config,
956 &rows,
957 )))?;
958 return Ok(0);
959 }
960 let labelled: Vec<_> = rows
961 .into_iter()
962 .map(|(resolution, presence)| (resolution, presence.map(|p| p.label())))
963 .collect();
964 output::status_lines(&discovery, &config, &labelled)
965 }
966 CommandWord::Link => {
967 let Discovery::Repository(repository) = discovery else {
968 return Err(Error::Repository {
969 path: repo::canonical(&start).unwrap_or(start),
970 reason: "not inside a Git repository".to_owned(),
971 });
972 };
973 let profile = profile.expect("`link` has a profile");
974 let outcome = config::link(&root, &repository, agent.as_ref(), &profile)?;
975 let mut lines =
976 vec![output::link_line(&outcome, agent.as_ref(), &repository, &profile)];
977 if let Some(agent) = &agent {
978 let adapter =
979 adapter::lookup(agent.as_str()).expect("known agents have an adapter");
980 if adapter.presence(&root, &profile) == ProfilePresence::Absent {
981 lines.push(output::note(&format!(
982 "profile {profile} has not been launched with {agent} yet"
983 )));
984 }
985 }
986 lines
987 }
988 CommandWord::Unlink => unreachable!("unlink returned above"),
989 CommandWord::Create
990 | CommandWord::Delete
991 | CommandWord::List
992 | CommandWord::Agents
993 | CommandWord::Profiles
994 | CommandWord::Repositories => unreachable!("lifecycle commands returned above"),
995 CommandWord::Doctor => unreachable!("doctor returned above"),
996 CommandWord::Completions => unreachable!("completions returned above"),
997 };
998 write_lines(&lines)?;
999 Ok(0)
1000}
1001
1002fn run_profiles(root: &AppRoot, agent: Option<&AgentId>, json: bool) -> Result<i32> {
1007 let adapters = adapter::registry();
1008 let listing = lifecycle::list_profiles(root, &adapters)?;
1009 let document = json::profiles(&listing, agent);
1010 let no_rows = document.is_empty();
1011 if json {
1012 write_out(&json::render(&document))?;
1013 } else {
1014 let lines = match agent {
1015 Some(agent) => output::list_lines(&listing, agent.as_str()),
1016 None => output::profiles_lines(&listing),
1017 };
1018 write_lines(&lines)?;
1019 }
1020 if listing.ignored > 0 {
1021 let _ = writeln!(
1022 io::stderr(),
1023 "agent-profile: ignored {} unrecognized entries",
1024 listing.ignored
1025 );
1026 }
1027 if let Some(agent) = agent
1028 && no_rows
1029 {
1030 let _ = writeln!(io::stderr(), "agent-profile: no profiles for {agent}");
1031 }
1032 Ok(0)
1033}
1034
1035fn run_repositories(root: &AppRoot, config: &Config, json: bool) -> Result<i32> {
1038 let entries = repositories::report(root, config);
1039 if json {
1040 write_out(&json::render(&json::repositories(&entries)))?;
1041 } else {
1042 write_lines(&output::repositories_lines(&entries))?;
1043 }
1044 Ok(0)
1045}
1046
1047fn run_agents(root: &AppRoot, config: &Config, json: bool) -> Result<i32> {
1052 let path_var = std::env::var_os("PATH");
1053 let mut entries = Vec::new();
1054 for adapter in adapter::registry() {
1055 let metadata = adapter.metadata();
1056 let status = exe::ExecutableStatus::discover(
1057 metadata,
1058 config.agent_executable(metadata.id),
1059 path_var.as_deref(),
1060 &root.config_path(),
1061 )?;
1062 entries.push((metadata, status));
1063 }
1064 if json {
1065 write_out(&json::render(&json::agents(&entries)))?;
1066 } else {
1067 write_lines(&output::agents_lines(&entries))?;
1068 }
1069 Ok(0)
1070}
1071
1072fn run_create(root: &AppRoot, agent: &AgentId, profile: &ProfileName) -> Result<i32> {
1076 let adapter = adapter::lookup(agent.as_str()).expect("known agents have an adapter");
1077 let (outcome, path) = lifecycle::create(root, adapter, profile)?;
1078 write_lines(&[output::create_line(&outcome, agent.as_str(), profile, &path)])?;
1079 Ok(0)
1080}
1081
1082fn confirm(question: &str, input: &mut dyn BufRead) -> Result<bool> {
1085 let mut stderr = io::stderr();
1086 stderr.write_all(question.as_bytes()).and_then(|()| stderr.flush()).map_err(|source| {
1087 Error::Io { context: "could not write the confirmation prompt".to_owned(), source }
1088 })?;
1089 let mut line = String::new();
1090 input.read_line(&mut line).map_err(|source| Error::Io {
1091 context: "could not read the confirmation answer".to_owned(),
1092 source,
1093 })?;
1094 let answer = line.trim();
1095 Ok(answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes"))
1096}
1097
1098fn run_delete(
1104 root: &AppRoot,
1105 config: &Config,
1106 agent: &AgentId,
1107 profile: &ProfileName,
1108 yes: bool,
1109) -> Result<i32> {
1110 let references = lifecycle::blocking_references(config, agent, profile);
1111 match lifecycle::delete_target(root, agent, profile)? {
1112 None => {
1113 write_lines(&[output::missing_profile_line(agent.as_str(), profile)])?;
1114 if !references.is_empty() {
1117 let _ = writeln!(
1118 io::stderr(),
1119 "agent-profile: note: still referenced by {}; unlink it before deleting",
1120 references.join(", ")
1121 );
1122 }
1123 Ok(0)
1124 }
1125 Some(path) => {
1126 if !references.is_empty() {
1130 return Err(Error::ProfileReferenced {
1131 agent: agent.to_string(),
1132 profile: profile.to_string(),
1133 references,
1134 });
1135 }
1136 if !yes {
1137 if !io::stdin().is_terminal() {
1138 return Err(usage("--yes is required when input is not a terminal"));
1139 }
1140 if !confirm(
1141 &format!(
1142 "delete profile '{profile}' for {agent} at {}? [y/N] ",
1143 path.display()
1144 ),
1145 &mut io::stdin().lock(),
1146 )? {
1147 let _ = writeln!(io::stderr(), "agent-profile: not deleted");
1148 return Ok(1);
1149 }
1150 }
1151 let outcome = lifecycle::delete(root, agent, profile)?;
1152 if let lifecycle::DeleteOutcome::Deleted { parent_note: Some(note), .. } = &outcome {
1153 let _ = writeln!(io::stderr(), "agent-profile: note: {note}");
1154 }
1155 write_lines(&[output::delete_line(&outcome, agent.as_str(), profile)])?;
1156 Ok(0)
1157 }
1158 }
1159}
1160
1161fn run_unlink(
1163 root: &AppRoot,
1164 config: &Config,
1165 agent: Option<&AgentId>,
1166 cwd: &std::path::Path,
1167 repo: Option<&OsStr>,
1168) -> Result<i32> {
1169 let keys = match repo {
1170 Some(repo) => repo::unlink_keys(cwd, repo),
1171 None => match repo::discover(cwd)? {
1172 Discovery::Repository(repository) => vec![repository],
1173 Discovery::NotInRepository => {
1174 return Err(Error::Repository {
1175 path: repo::canonical(cwd).unwrap_or_else(|_| cwd.to_path_buf()),
1176 reason: "not inside a Git repository".to_owned(),
1177 });
1178 }
1179 },
1180 };
1181 let outcome = config::unlink(root, &keys, agent)?;
1182 write_lines(&output::unlink_lines(&outcome, agent, config, &keys))?;
1183 Ok(0)
1184}
1185
1186fn run_doctor(
1189 agent: Option<String>,
1190 profile: Option<String>,
1191 repo: Option<OsString>,
1192 json: bool,
1193) -> Result<i32> {
1194 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1195 let start = match &repo {
1196 Some(repo) => cwd.join(repo),
1197 None => cwd.clone(),
1198 };
1199 let path_var = std::env::var_os("PATH");
1200 let is_set = |name: &str| std::env::var_os(name).is_some();
1201 let env = doctor::Environment {
1202 root: AppRoot::resolve().map_err(|error| error.to_string()),
1203 path_var: path_var.as_deref(),
1204 cwd: &cwd,
1205 start: &start,
1206 is_set: &is_set,
1207 };
1208 let target = match agent {
1209 Some(agent) => doctor::Target::Agent {
1210 agent: AgentId::parse(&agent).expect("known agents are valid agent ids"),
1211 profile,
1212 },
1213 None => doctor::Target::Host,
1214 };
1215 let report = doctor::run(&target, &env);
1216 if json {
1217 write_out(&json::render(&json::doctor(&report)))?;
1218 } else {
1219 write_lines(&output::doctor_lines(&report))?;
1220 }
1221 Ok(if report.failed() { 5 } else { 0 })
1222}
1223
1224fn write_lines(lines: &[String]) -> Result<()> {
1225 write_out(&lines.iter().map(|line| format!("{line}\n")).collect::<String>())
1226}
1227
1228fn write_out(text: &str) -> Result<()> {
1229 let mut stdout = io::stdout();
1230 stdout
1231 .write_all(text.as_bytes())
1232 .and_then(|()| stdout.flush())
1233 .map_err(|source| Error::Io { context: "could not write to stdout".to_owned(), source })
1234}
1235
1236fn with_unknown_configured(error: Error, config: Option<&Config>, known: &[&str]) -> Error {
1239 let unknown = |config: Option<&Config>| -> Vec<String> {
1240 let loaded;
1241 let config = match config {
1242 Some(config) => Some(config),
1243 None => {
1244 loaded = AppRoot::resolve().ok().and_then(|root| Config::load(&root).ok());
1245 loaded.as_ref()
1246 }
1247 };
1248 config
1249 .map(|config| {
1250 config
1251 .configured_agents()
1252 .filter(|id| !known.contains(id))
1253 .map(String::from)
1254 .collect()
1255 })
1256 .unwrap_or_default()
1257 };
1258 match error {
1259 Error::UnknownAgent { agent, known: names, .. } => {
1260 Error::UnknownAgent { agent, known: names, unknown_configured: unknown(config) }
1261 }
1262 Error::AgentNotInstalled { agent, reason, .. } => {
1263 Error::AgentNotInstalled { agent, reason, unknown_configured: unknown(config) }
1264 }
1265 other => other,
1266 }
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271 use super::*;
1272
1273 const KNOWN: &[&str] = &["fake", "claude"];
1274
1275 fn args(items: &[&str]) -> Vec<OsString> {
1276 items.iter().map(OsString::from).collect()
1277 }
1278
1279 fn launch(profile: Option<&str>, dry_run: bool, verbose: bool, opaque: &[&str]) -> Invocation {
1280 Invocation::Launch {
1281 agent: "fake".to_owned(),
1282 profile: profile.map(str::to_owned),
1283 dry_run,
1284 verbose,
1285 opaque: args(opaque),
1286 }
1287 }
1288
1289 fn command(
1290 agent: Option<&str>,
1291 command: CommandWord,
1292 profile: Option<&str>,
1293 repo: Option<&str>,
1294 yes: bool,
1295 json: bool,
1296 ) -> Invocation {
1297 Invocation::Command {
1298 agent: agent.map(str::to_owned),
1299 command,
1300 profile: profile.map(str::to_owned),
1301 repo: repo.map(OsString::from),
1302 yes,
1303 json,
1304 }
1305 }
1306
1307 fn split_ok(items: &[&str]) -> Invocation {
1308 split(args(items), KNOWN).unwrap()
1309 }
1310
1311 fn split_err(items: &[&str]) -> Error {
1312 split(args(items), KNOWN).unwrap_err()
1313 }
1314
1315 fn top(items: &[&str]) -> Result<Invocation> {
1317 let command = CommandWord::parse(items[0]).expect("a top-level command word");
1318 split_top_level(command, args(&items[1..]))
1319 }
1320
1321 fn usage_message(result: Result<Invocation>) -> String {
1322 match result {
1323 Err(Error::Usage { message }) => message,
1324 other => panic!("{other:?}"),
1325 }
1326 }
1327
1328 #[test]
1329 fn confirm_accepts_only_y_or_yes_and_eof_is_no() {
1330 use std::io::Cursor;
1331 for (input, expected) in [
1332 ("y\n", true),
1333 ("YES\n", true),
1334 (" yes \n", true),
1335 ("n\n", false),
1336 ("yes please\n", false),
1337 ("", false),
1338 ("\n", false),
1339 ] {
1340 assert_eq!(confirm("q? ", &mut Cursor::new(input)).unwrap(), expected, "{input:?}");
1341 }
1342 }
1343
1344 #[test]
1345 fn cut_is_at_the_first_double_dash_and_later_ones_are_opaque() {
1346 assert_eq!(
1347 split_ok(&["fake", "work", "--", "--dry-run", "--", "a b"]),
1348 launch(Some("work"), false, false, &["--dry-run", "--", "a b"])
1349 );
1350 assert_eq!(split_ok(&["fake", "--", "work"]), launch(None, false, false, &["work"]));
1351 assert_eq!(
1352 split_ok(&["fake", "work", "--", "--help"]),
1353 launch(Some("work"), false, false, &["--help"])
1354 );
1355 }
1356
1357 #[test]
1358 fn options_may_appear_anywhere_before_the_cut() {
1359 assert_eq!(
1360 split_ok(&["fake", "--dry-run", "work", "--verbose"]),
1361 launch(Some("work"), true, true, &[])
1362 );
1363 assert_eq!(split_ok(&["fake"]), launch(None, false, false, &[]));
1364 }
1365
1366 #[test]
1367 fn help_and_version_win_first() {
1368 assert_eq!(split_ok(&["zzz", "--help"]), Invocation::Help);
1369 assert_eq!(
1370 split_ok(&["fake", "create", "-h"]),
1371 Invocation::CommandHelp(CommandWord::Create)
1372 );
1373 assert_eq!(split_ok(&["fake", "-h", "--", "x"]), Invocation::Help);
1374 assert_eq!(split_ok(&["zzz", "--version"]), Invocation::Version);
1375 assert_eq!(split_ok(&["fake", "work", "-V"]), Invocation::Version);
1376 }
1377
1378 #[test]
1379 fn unknown_agent_is_exact_and_precedes_reserved_words() {
1380 for items in [&["zzz", "work"][..], &["Fake", "work"], &["zzz", "create"], &["zzz", "link"]]
1381 {
1382 assert!(matches!(split_err(items), Error::UnknownAgent { .. }), "{items:?}");
1383 }
1384 }
1385
1386 #[test]
1387 fn reserved_first_bare_word_routes_by_exact_spelling() {
1388 assert_eq!(
1389 split_ok(&["fake", "create", "work"]),
1390 command(Some("fake"), CommandWord::Create, Some("work"), None, false, false)
1391 );
1392 for (items, word) in [
1393 (&["fake", "CREATE", "--bogus"][..], "create"),
1394 (&["fake", "Create", "x"], "create"),
1395 (&["claude", "LINK"], "link"),
1396 ] {
1397 assert_eq!(
1398 usage_message(split(args(items), KNOWN)),
1399 format!("command words are lower case: `{word}`"),
1400 "{items:?}"
1401 );
1402 }
1403 assert_eq!(
1404 usage_message(split(args(&["fake", "--bogus", "link"]), KNOWN)),
1405 "unknown option \"--bogus\" for `link`"
1406 );
1407 }
1408
1409 #[test]
1410 fn agent_scoped_commands_bind_their_words_and_repo() {
1411 assert_eq!(
1412 split_ok(&["claude", "current"]),
1413 command(Some("claude"), CommandWord::Current, None, None, false, false)
1414 );
1415 assert_eq!(
1416 split_ok(&["claude", "resolve", "--repo", "../x"]),
1417 command(Some("claude"), CommandWord::Resolve, None, Some("../x"), false, false)
1418 );
1419 assert_eq!(
1420 split_ok(&["claude", "--repo=a=b", "status"]),
1421 command(Some("claude"), CommandWord::Status, None, Some("a=b"), false, false)
1422 );
1423 assert_eq!(
1424 split_ok(&["claude", "link", "work", "--repo", "-dir"]),
1425 command(Some("claude"), CommandWord::Link, Some("work"), Some("-dir"), false, false)
1426 );
1427 assert_eq!(
1428 split_ok(&["claude", "unlink", "--repo", "link"]),
1429 command(Some("claude"), CommandWord::Unlink, None, Some("link"), false, false)
1430 );
1431 }
1432
1433 #[test]
1434 fn top_level_commands_take_every_raw_token() {
1435 assert_eq!(
1436 top(&["status"]).unwrap(),
1437 command(None, CommandWord::Status, None, None, false, false)
1438 );
1439 assert_eq!(
1440 top(&["link", "work", "--repo", "r"]).unwrap(),
1441 command(None, CommandWord::Link, Some("work"), Some("r"), false, false)
1442 );
1443 assert_eq!(
1444 top(&["link", "status"]).unwrap(),
1445 command(None, CommandWord::Link, Some("status"), None, false, false)
1446 );
1447 assert_eq!(
1448 top(&["link", "Create"]).unwrap(),
1449 command(None, CommandWord::Link, Some("Create"), None, false, false)
1450 );
1451 assert_eq!(
1452 top(&["unlink", "--repo=/gone"]).unwrap(),
1453 command(None, CommandWord::Unlink, None, Some("/gone"), false, false)
1454 );
1455 }
1456
1457 #[test]
1458 fn command_help_names_the_command_word() {
1459 assert_eq!(top(&["link", "-h"]).unwrap(), Invocation::CommandHelp(CommandWord::Link));
1460 assert_eq!(
1461 top(&["link", "status", "-h"]).unwrap(),
1462 Invocation::CommandHelp(CommandWord::Link)
1463 );
1464 assert_eq!(top(&["resolve", "-h"]).unwrap(), Invocation::CommandHelp(CommandWord::Resolve));
1465 assert_eq!(top(&["status", "-V"]).unwrap(), Invocation::Version);
1466 assert_eq!(
1467 split_ok(&["claude", "status", "--help"]),
1468 Invocation::CommandHelp(CommandWord::Status)
1469 );
1470 assert_eq!(split_ok(&["claude", "Status", "--help"]), Invocation::Help);
1471 }
1472
1473 #[test]
1474 fn a_consumed_repo_value_is_never_help_an_option_or_a_command_word() {
1475 assert_eq!(
1476 split_ok(&["claude", "link", "work", "--repo", "-h"]),
1477 command(Some("claude"), CommandWord::Link, Some("work"), Some("-h"), false, false)
1478 );
1479 assert_eq!(
1480 usage_message(split(args(&["claude", "--repo"]), KNOWN)),
1481 "`--repo` needs a path"
1482 );
1483 assert_eq!(
1484 usage_message(split(args(&["fake", "-h", "--repo"]), KNOWN)),
1485 "`--repo` needs a path"
1486 );
1487 assert_eq!(
1488 usage_message(split(args(&["fake", "work", "--repo", "--help"]), KNOWN)),
1489 "unknown option \"--repo\"; agent arguments must follow `--`"
1490 );
1491 assert_eq!(
1492 usage_message(split(args(&["fake", "--repo", "create"]), KNOWN)),
1493 "unknown option \"--repo\"; agent arguments must follow `--`"
1494 );
1495 }
1496
1497 #[test]
1498 fn command_usage_errors_in_order() {
1499 for (items, message) in [
1500 (&["status", "--"][..], "`status` takes no agent arguments; remove `--`"),
1501 (&["unlink", "--", "--repo", "x"], "`unlink` takes no agent arguments; remove `--`"),
1502 (&["link", "work", "--repo", "a", "--repo=b"], "`--repo` may be given only once"),
1503 (&["link", "work", "--repo="], "`--repo` needs a non-empty path"),
1504 (&["link", "work", "--repo", ""], "`--repo` needs a non-empty path"),
1505 (&["link", "work", "--json"], "unknown option \"--json\" for `link`"),
1506 (&["unlink", "--dry-run"], "unknown option \"--dry-run\" for `unlink`"),
1507 (&["status", "--verbose=yes"], "unknown option \"--verbose\" for `status`"),
1508 (&["link"], "`link` needs a profile: agent-profile [<agent>] link <profile>"),
1509 (
1510 &["link", "--repo", "r"],
1511 "`link` needs a profile: agent-profile [<agent>] link <profile>",
1512 ),
1513 (&["link", "work", "extra"], "`link` takes one profile"),
1514 (&["status", "extra"], "`status` takes no arguments"),
1515 (&["unlink", "extra"], "`unlink` takes no arguments"),
1516 (&["resolve"], "`resolve` needs an agent: agent-profile <agent> resolve"),
1517 (
1518 &["current", "--repo", "x"],
1519 "`current` needs an agent: agent-profile <agent> current",
1520 ),
1521 ] {
1522 assert_eq!(usage_message(top(items)), message, "{items:?}");
1523 }
1524 for (items, message) in [
1525 (&["claude", "current", "extra"][..], "`current` takes no arguments"),
1526 (
1527 &["claude", "resolve", "--json", "--bogus"],
1528 "unknown option \"--bogus\" for `resolve`",
1529 ),
1530 (&["claude", "current", "--json"], "unknown option \"--json\" for `current`"),
1531 (&["claude", "link", "a", "--", "x"], "`link` takes no agent arguments; remove `--`"),
1532 ] {
1533 let result = split(args(items), KNOWN);
1534 let text = match result {
1535 Err(error @ (Error::Usage { .. } | Error::NotYetImplemented { .. })) => {
1536 error.to_string()
1537 }
1538 other => panic!("{items:?}: {other:?}"),
1539 };
1540 assert_eq!(text, message, "{items:?}");
1541 }
1542 assert!(matches!(top(&["status", "--json"]), Ok(Invocation::Command { json: true, .. })));
1543 }
1544
1545 #[test]
1546 fn lifecycle_words_bind_agent_profile_and_yes() {
1547 assert_eq!(
1548 split_ok(&["fake", "create", "work"]),
1549 command(Some("fake"), CommandWord::Create, Some("work"), None, false, false)
1550 );
1551 assert_eq!(
1552 split_ok(&["fake", "delete", "work", "--yes"]),
1553 command(Some("fake"), CommandWord::Delete, Some("work"), None, true, false)
1554 );
1555 assert_eq!(
1556 split_ok(&["fake", "--yes", "delete", "work"]),
1557 command(Some("fake"), CommandWord::Delete, Some("work"), None, true, false)
1558 );
1559 assert_eq!(
1560 split_ok(&["fake", "list"]),
1561 command(Some("fake"), CommandWord::List, None, None, false, false)
1562 );
1563 assert_eq!(
1564 top(&["list"]).unwrap(),
1565 command(None, CommandWord::List, None, None, false, false)
1566 );
1567 assert_eq!(
1568 top(&["agents"]).unwrap(),
1569 command(None, CommandWord::Agents, None, None, false, false)
1570 );
1571 assert_eq!(
1572 top(&["profiles"]).unwrap(),
1573 command(None, CommandWord::Profiles, None, None, false, false)
1574 );
1575 assert_eq!(top(&["create", "-h"]).unwrap(), Invocation::CommandHelp(CommandWord::Create));
1576 }
1577
1578 #[test]
1579 fn lifecycle_usage_errors() {
1580 for (items, message) in [
1581 (&["create"][..], "`create` needs an agent: agent-profile <agent> create <profile>"),
1582 (
1583 &["delete", "work", "--yes"],
1584 "`delete` needs an agent: agent-profile <agent> delete <profile>",
1585 ),
1586 (&["agents", "--repo", "x"], "unknown option \"--repo\" for `agents`"),
1587 (&["profiles", "extra"], "`profiles` takes no arguments"),
1588 (&["list", "--yes"], "unknown option \"--yes\" for `list`"),
1589 ] {
1590 assert_eq!(usage_message(top(items)), message, "{items:?}");
1591 }
1592 for (items, message) in [
1593 (
1594 &["fake", "create"][..],
1595 "`create` needs a profile: agent-profile <agent> create <profile>",
1596 ),
1597 (
1598 &["fake", "delete"],
1599 "`delete` needs a profile: agent-profile <agent> delete <profile>",
1600 ),
1601 (&["fake", "create", "a", "b"], "`create` takes one profile"),
1602 (&["fake", "create", "work", "--repo", "x"], "unknown option \"--repo\" for `create`"),
1603 (&["fake", "create", "work", "--yes"], "unknown option \"--yes\" for `create`"),
1604 (&["fake", "delete", "work", "--yes=1"], "unknown option \"--yes\" for `delete`"),
1605 (&["fake", "agents"], "`agents` takes no agent: agent-profile agents"),
1606 (&["fake", "profiles"], "`profiles` takes no agent: agent-profile profiles"),
1607 (&["fake", "list", "extra"], "`list` takes no arguments"),
1608 (
1609 &["fake", "create", "work", "--", "x"],
1610 "`create` takes no agent arguments; remove `--`",
1611 ),
1612 ] {
1613 assert_eq!(usage_message(split(args(items), KNOWN)), message, "{items:?}");
1614 }
1615 for items in
1616 [&["fake", "create", "work", "--json"][..], &["fake", "delete", "work", "--json"]]
1617 {
1618 assert!(matches!(split_err(items), Error::NotYetImplemented { .. }), "{items:?}");
1619 }
1620 assert!(matches!(
1621 split(args(&["fake", "list", "--json"]), KNOWN),
1622 Ok(Invocation::Command { json: true, .. })
1623 ));
1624 assert!(matches!(top(&["agents", "--json"]), Ok(Invocation::Command { json: true, .. })));
1625 }
1626
1627 #[test]
1628 fn repositories_is_a_top_level_command_without_agent_arguments_and_takes_json() {
1629 assert!(matches!(
1630 top(&["repositories"]),
1631 Ok(Invocation::Command { command: CommandWord::Repositories, json: false, .. })
1632 ));
1633 assert_eq!(
1634 usage_message(Err(split_err(&["fake", "repositories"]))),
1635 "`repositories` takes no agent: agent-profile repositories"
1636 );
1637 assert!(matches!(
1638 top(&["repositories", "--json"]),
1639 Ok(Invocation::Command { command: CommandWord::Repositories, json: true, .. })
1640 ));
1641 }
1642
1643 #[test]
1644 fn doctor_parses_in_both_forms() {
1645 assert!(matches!(
1646 top(&["doctor"]),
1647 Ok(Invocation::Command {
1648 agent: None,
1649 command: CommandWord::Doctor,
1650 profile: None,
1651 ..
1652 })
1653 ));
1654 match split(args(&["fake", "doctor", "work", "--json"]), KNOWN) {
1655 Ok(Invocation::Command {
1656 agent: Some(agent),
1657 command: CommandWord::Doctor,
1658 profile: Some(profile),
1659 json: true,
1660 ..
1661 }) => assert_eq!((agent.as_str(), profile.as_str()), ("fake", "work")),
1662 other => panic!("{other:?}"),
1663 }
1664 assert!(matches!(
1665 split(args(&["fake", "doctor"]), KNOWN),
1666 Ok(Invocation::Command { command: CommandWord::Doctor, profile: None, .. })
1667 ));
1668 }
1669
1670 #[test]
1671 fn doctor_refuses_extra_words() {
1672 assert_eq!(usage_message(top(&["doctor", "work"])), "`doctor` takes no arguments");
1673 assert_eq!(
1674 usage_message(Err(split_err(&["fake", "doctor", "a", "b"]))),
1675 "`doctor` takes one profile"
1676 );
1677 }
1678
1679 #[test]
1680 fn launch_discovery_runs_only_when_it_can_matter() {
1681 assert_eq!(launch_discovery(false, false, false), LaunchDiscovery::Required);
1682 assert_eq!(launch_discovery(false, true, true), LaunchDiscovery::Required);
1683 assert_eq!(launch_discovery(true, true, false), LaunchDiscovery::ReportOnly);
1684 assert_eq!(launch_discovery(true, false, true), LaunchDiscovery::ReportOnly);
1685 assert_eq!(launch_discovery(true, false, false), LaunchDiscovery::Skipped);
1686 }
1687
1688 #[test]
1689 fn an_absolute_repo_is_joined_to_nothing_so_no_current_directory_is_needed() {
1690 let absolute = if cfg!(windows) { r"C:\src\acme" } else { "/src/acme" };
1691 assert_eq!(base_dir(Some(OsStr::new(absolute))).unwrap(), PathBuf::new());
1692 assert_eq!(PathBuf::new().join(absolute), PathBuf::from(absolute));
1693 let cwd = std::env::current_dir().unwrap();
1694 assert_eq!(base_dir(Some(OsStr::new("relative"))).unwrap(), cwd);
1695 assert_eq!(base_dir(None).unwrap(), cwd);
1696 }
1697
1698 #[test]
1701 fn every_repo_spelling_names_the_same_target_with_or_without_the_working_directory() {
1702 let cwd = std::env::current_dir().unwrap();
1703 let spellings: &[&str] = if cfg!(windows) {
1704 &[
1705 r"C:\src\acme",
1706 r"C:\",
1707 "C:/src/acme",
1708 r"\\server\share\x",
1709 r"\\?\C:\x",
1710 r"\\?\UNC\server\share\x",
1711 r"\\.\pipe\x",
1712 r"\foo",
1713 "C:rel",
1714 r"..\up",
1715 "relative",
1716 ]
1717 } else {
1718 &["/src/acme", "/", "../up", "relative", "./relative"]
1719 };
1720 for spelling in spellings {
1721 let base = base_dir(Some(OsStr::new(spelling))).unwrap();
1722 assert_eq!(
1726 base.as_os_str().is_empty(),
1727 std::path::Path::new(spelling).is_absolute(),
1728 "{spelling:?} took the wrong branch of base_dir"
1729 );
1730 assert_eq!(
1731 base.join(spelling),
1732 cwd.join(spelling),
1733 "{spelling:?} resolves differently without the working directory"
1734 );
1735 }
1736 }
1737
1738 #[test]
1739 fn usage_errors() {
1740 for items in [
1741 &["fake", "work", "--bogus"][..],
1742 &["fake", "-"],
1743 &["fake", "--dry-run=yes", "work"],
1744 &["fake", "work", "extra"],
1745 &["fake", "", "extra"],
1746 &["fake", "work", "--json", "extra"],
1747 ] {
1748 assert!(matches!(split_err(items), Error::Usage { .. }), "{items:?}");
1749 }
1750 }
1751
1752 #[test]
1753 fn unknown_option_is_reported_before_extra_bare_words() {
1754 match split_err(&["fake", "work", "extra", "--bogus"]) {
1755 Error::Usage { message } => {
1756 assert!(message.starts_with("unknown option \"--bogus\""), "{message}")
1757 }
1758 other => panic!("{other:?}"),
1759 }
1760 }
1761
1762 #[test]
1763 fn unknown_option_error_never_echoes_its_value() {
1764 match split_err(&["fake", "work", "--openai-api-key=sk-secret=="]) {
1765 Error::Usage { message } => {
1766 assert!(message.starts_with("unknown option \"--openai-api-key\";"), "{message}");
1767 assert!(!message.contains("sk-secret"), "{message}");
1768 }
1769 other => panic!("{other:?}"),
1770 }
1771 let message = usage_message(top(&["status", "--api-key=sk-secret"]));
1772 assert_eq!(message, "unknown option \"--api-key\" for `status`");
1773 }
1774
1775 #[test]
1776 fn json_binds_once_after_the_command_word_for_the_report_commands() {
1777 for items in [
1778 &["status", "--json"][..],
1779 &["profiles", "--json"],
1780 &["agents", "--json"],
1781 &["list", "--json"],
1782 ] {
1783 assert!(matches!(top(items), Ok(Invocation::Command { json: true, .. })), "{items:?}");
1784 }
1785 for items in [
1786 &["fake", "resolve", "--json"][..],
1787 &["fake", "status", "--json", "--repo", "x"],
1788 &["fake", "list", "--json"],
1789 ] {
1790 assert!(
1791 matches!(split(args(items), KNOWN), Ok(Invocation::Command { json: true, .. })),
1792 "{items:?}"
1793 );
1794 }
1795 assert_eq!(
1796 usage_message(top(&["status", "--json", "--json"])),
1797 "`--json` may be given only once"
1798 );
1799 assert_eq!(
1800 usage_message(top(&["status", "--json=1"])),
1801 "unknown option \"--json\" for `status`"
1802 );
1803 for items in
1804 [&["fake", "create", "work", "--json"][..], &["fake", "delete", "work", "--json"]]
1805 {
1806 assert!(matches!(split_err(items), Error::NotYetImplemented { .. }), "{items:?}");
1807 }
1808 assert_eq!(
1809 usage_message(split(args(&["fake", "current", "--json"]), KNOWN)),
1810 "unknown option \"--json\" for `current`"
1811 );
1812 assert_eq!(
1813 usage_message(split(args(&["fake", "link", "work", "--json"]), KNOWN)),
1814 "unknown option \"--json\" for `link`"
1815 );
1816 assert_eq!(
1817 usage_message(split(args(&["fake", "unlink", "--json"]), KNOWN)),
1818 "unknown option \"--json\" for `unlink`"
1819 );
1820 }
1821
1822 #[test]
1823 fn json_is_not_yet_implemented() {
1824 for items in [&["fake", "work", "--json"][..], &["fake", "--json"]] {
1825 match split_err(items) {
1826 Error::NotYetImplemented { command } => assert_eq!(command, "--json"),
1827 other => panic!("{items:?}: {other:?}"),
1828 }
1829 }
1830 }
1831
1832 #[test]
1833 fn empty_string_is_a_profile_word() {
1834 assert_eq!(split_ok(&["fake", ""]), launch(Some(""), false, false, &[]));
1835 }
1836
1837 #[cfg(unix)]
1838 fn non_utf8() -> OsString {
1839 use std::os::unix::ffi::OsStringExt;
1840 OsString::from_vec(vec![0x66, 0xff])
1841 }
1842
1843 #[cfg(windows)]
1844 fn non_utf8() -> OsString {
1845 use std::os::windows::ffi::OsStringExt;
1846 OsString::from_wide(&[0x66, 0xD800])
1847 }
1848
1849 #[test]
1850 fn non_utf8_words() {
1851 let bare = vec![OsString::from("fake"), non_utf8()];
1852 assert!(matches!(split(bare, KNOWN), Err(Error::Usage { .. })));
1853 let agent = vec![non_utf8(), OsString::from("work")];
1854 assert!(matches!(split(agent, KNOWN), Err(Error::UnknownAgent { .. })));
1855 let opaque = vec![OsString::from("fake"), "work".into(), "--".into(), non_utf8()];
1856 match split(opaque, KNOWN).unwrap() {
1857 Invocation::Launch { opaque, .. } => assert_eq!(opaque, vec![non_utf8()]),
1858 other => panic!("{other:?}"),
1859 }
1860 let link = vec![OsString::from("fake"), "link".into(), non_utf8()];
1861 assert_eq!(usage_message(split(link, KNOWN)), "the profile name is not valid UTF-8");
1862 let mut equals = OsString::from("--repo=");
1863 equals.push(non_utf8());
1864 for repo in [vec![OsString::from("--repo"), non_utf8()], vec![equals]] {
1865 let mut items = vec![OsString::from("fake"), "status".into()];
1866 items.extend(repo);
1867 assert_eq!(
1868 split(items, KNOWN).unwrap(),
1869 Invocation::Command {
1870 agent: Some("fake".to_owned()),
1871 command: CommandWord::Status,
1872 profile: None,
1873 repo: Some(non_utf8()),
1874 yes: false,
1875 json: false,
1876 }
1877 );
1878 }
1879 }
1880
1881 struct Row {
1882 word: CommandWord,
1883 scope: AgentScope,
1884 repo: bool,
1885 json: bool,
1886 yes: bool,
1887 top_level: Positional,
1888 with_agent: Positional,
1889 }
1890 const TABLE: &[Row] = &[
1891 Row {
1893 word: CommandWord::Current,
1894 scope: AgentScope::AgentRequired,
1895 repo: true,
1896 json: false,
1897 yes: false,
1898 top_level: Positional::None,
1899 with_agent: Positional::None,
1900 },
1901 Row {
1902 word: CommandWord::Resolve,
1903 scope: AgentScope::AgentRequired,
1904 repo: true,
1905 json: true,
1906 yes: false,
1907 top_level: Positional::None,
1908 with_agent: Positional::None,
1909 },
1910 Row {
1911 word: CommandWord::Status,
1912 scope: AgentScope::Either,
1913 repo: true,
1914 json: true,
1915 yes: false,
1916 top_level: Positional::None,
1917 with_agent: Positional::None,
1918 },
1919 Row {
1920 word: CommandWord::Link,
1921 scope: AgentScope::Either,
1922 repo: true,
1923 json: false,
1924 yes: false,
1925 top_level: Positional::RequiredProfile,
1926 with_agent: Positional::RequiredProfile,
1927 },
1928 Row {
1929 word: CommandWord::Unlink,
1930 scope: AgentScope::Either,
1931 repo: true,
1932 json: false,
1933 yes: false,
1934 top_level: Positional::None,
1935 with_agent: Positional::None,
1936 },
1937 Row {
1938 word: CommandWord::Create,
1939 scope: AgentScope::AgentRequired,
1940 repo: false,
1941 json: false,
1942 yes: false,
1943 top_level: Positional::RequiredProfile,
1944 with_agent: Positional::RequiredProfile,
1945 },
1946 Row {
1947 word: CommandWord::Delete,
1948 scope: AgentScope::AgentRequired,
1949 repo: false,
1950 json: false,
1951 yes: true,
1952 top_level: Positional::RequiredProfile,
1953 with_agent: Positional::RequiredProfile,
1954 },
1955 Row {
1956 word: CommandWord::List,
1957 scope: AgentScope::Either,
1958 repo: false,
1959 json: true,
1960 yes: false,
1961 top_level: Positional::None,
1962 with_agent: Positional::None,
1963 },
1964 Row {
1965 word: CommandWord::Agents,
1966 scope: AgentScope::TopLevelOnly,
1967 repo: false,
1968 json: true,
1969 yes: false,
1970 top_level: Positional::None,
1971 with_agent: Positional::None,
1972 },
1973 Row {
1974 word: CommandWord::Profiles,
1975 scope: AgentScope::TopLevelOnly,
1976 repo: false,
1977 json: true,
1978 yes: false,
1979 top_level: Positional::None,
1980 with_agent: Positional::None,
1981 },
1982 Row {
1983 word: CommandWord::Repositories,
1984 scope: AgentScope::TopLevelOnly,
1985 repo: false,
1986 json: true,
1987 yes: false,
1988 top_level: Positional::None,
1989 with_agent: Positional::None,
1990 },
1991 Row {
1992 word: CommandWord::Doctor,
1993 scope: AgentScope::Either,
1994 repo: true,
1995 json: true,
1996 yes: false,
1997 top_level: Positional::None,
1998 with_agent: Positional::OptionalProfile,
1999 },
2000 Row {
2001 word: CommandWord::Completions,
2002 scope: AgentScope::TopLevelOnly,
2003 repo: false,
2004 json: false,
2005 yes: false,
2006 top_level: Positional::Shell,
2007 with_agent: Positional::Shell,
2008 },
2009 ];
2010
2011 #[test]
2012 fn grammar_table_is_pinned() {
2013 assert_eq!(CommandWord::ALL.len(), TABLE.len());
2014 for row in TABLE {
2015 assert!(CommandWord::ALL.contains(&row.word), "{:?} missing from ALL", row.word);
2016 }
2017 for word in CommandWord::ALL {
2018 assert!(TABLE.iter().any(|row| row.word == word), "{word:?} missing from TABLE");
2019 }
2020 for row in TABLE {
2021 let word = row.word;
2022 assert_eq!(word.scope(), row.scope, "{word:?} scope");
2023 assert_eq!(word.accepts_repo(), row.repo, "{word:?} repo");
2024 assert_eq!(word.accepts_json(), row.json, "{word:?} json");
2025 assert_eq!(word.accepts_yes(), row.yes, "{word:?} yes");
2026 assert_eq!(word.positional(false), row.top_level, "{word:?} top level");
2027 assert_eq!(word.positional(true), row.with_agent, "{word:?} with agent");
2028 }
2029 }
2030
2031 #[test]
2032 fn dispatcher_follows_the_grammar_table() {
2033 fn run(with_agent: bool, name: &str, rest: &[&str]) -> Result<Invocation> {
2034 let mut items = vec![name];
2035 items.extend_from_slice(rest);
2036 if with_agent {
2037 items.insert(0, "fake");
2038 split(args(&items), KNOWN)
2039 } else {
2040 top(&items)
2041 }
2042 }
2043 for row in TABLE {
2044 let name = row.word.as_str();
2045 let word = row.word;
2046 let profiles = |positional: Positional| -> &'static [&'static str] {
2047 match positional {
2048 Positional::RequiredProfile => &["p"],
2049 Positional::Shell => &["bash"],
2050 _ => &[],
2051 }
2052 };
2053
2054 let prof = profiles(row.top_level);
2056 let result = run(false, name, prof);
2057 if row.scope == AgentScope::AgentRequired {
2058 assert!(
2059 usage_message(result).starts_with(&format!("`{name}` needs an agent")),
2060 "{name} top level"
2061 );
2062 } else {
2063 assert_eq!(
2064 result.unwrap(),
2065 if row.top_level == Positional::Shell {
2066 Invocation::Completions(Shell::Bash)
2067 } else {
2068 command(None, word, prof.first().copied(), None, false, false)
2069 },
2070 "{name} top level"
2071 );
2072 }
2073
2074 let prof = profiles(row.with_agent);
2076 let result = run(true, name, prof);
2077 if row.scope == AgentScope::TopLevelOnly {
2078 assert_eq!(
2079 usage_message(result),
2080 format!("`{name}` takes no agent: agent-profile {name}")
2081 );
2082 } else {
2083 assert_eq!(
2084 result.unwrap(),
2085 command(Some("fake"), word, prof.first().copied(), None, false, false),
2086 "{name} with agent"
2087 );
2088 }
2089
2090 let with_agent = row.scope != AgentScope::TopLevelOnly;
2092 let agent = with_agent.then_some("fake");
2093 let positional = if with_agent { row.with_agent } else { row.top_level };
2094 let prof = profiles(positional);
2095 let with_flag = |flag: &[&str]| -> Result<Invocation> {
2096 let mut rest = flag.to_vec();
2097 rest.extend_from_slice(prof);
2098 run(with_agent, name, &rest)
2099 };
2100 let unknown = |option: &str| format!("unknown option \"{option}\" for `{name}`");
2101 let result = with_flag(&["--repo", "x"]);
2102 if row.repo {
2103 assert_eq!(
2104 result.unwrap(),
2105 command(agent, word, prof.first().copied(), Some("x"), false, false),
2106 "{name} --repo"
2107 );
2108 } else {
2109 assert_eq!(usage_message(result), unknown("--repo"));
2110 }
2111 let result = with_flag(&["--json"]);
2112 if row.json {
2113 assert_eq!(
2114 result.unwrap(),
2115 command(agent, word, prof.first().copied(), None, false, true),
2116 "{name} --json"
2117 );
2118 } else if matches!(word, CommandWord::Create | CommandWord::Delete) {
2119 assert!(
2120 matches!(result, Err(Error::NotYetImplemented { ref command }) if command == "--json"),
2121 "{name} --json: {result:?}"
2122 );
2123 } else {
2124 assert_eq!(usage_message(result), unknown("--json"));
2125 }
2126 let result = with_flag(&["--yes"]);
2127 if row.yes {
2128 assert_eq!(
2129 result.unwrap(),
2130 command(agent, word, prof.first().copied(), None, true, false),
2131 "{name} --yes"
2132 );
2133 } else {
2134 assert_eq!(usage_message(result), unknown("--yes"));
2135 }
2136
2137 for with_agent in [false, true] {
2139 let valid = match row.scope {
2140 AgentScope::TopLevelOnly => !with_agent,
2141 AgentScope::AgentRequired => with_agent,
2142 AgentScope::Either => true,
2143 };
2144 if !valid {
2145 continue;
2146 }
2147 let positional = if with_agent { row.with_agent } else { row.top_level };
2148 match positional {
2149 Positional::RequiredProfile => {
2150 assert!(
2151 usage_message(run(with_agent, name, &[]))
2152 .starts_with(&format!("`{name}` needs a profile")),
2153 "{name} bare"
2154 );
2155 assert_eq!(
2156 usage_message(run(with_agent, name, &["p", "q"])),
2157 format!("`{name}` takes one profile")
2158 );
2159 }
2160 Positional::None => {
2161 assert_eq!(
2162 usage_message(run(with_agent, name, &["p"])),
2163 format!("`{name}` takes no arguments")
2164 );
2165 }
2166 Positional::OptionalProfile => {
2167 assert_eq!(
2168 run(with_agent, name, &["p"]).unwrap(),
2169 command(Some("fake"), word, Some("p"), None, false, false)
2170 );
2171 assert_eq!(
2172 usage_message(run(with_agent, name, &["p", "q"])),
2173 format!("`{name}` takes one profile")
2174 );
2175 }
2176 Positional::Shell => {
2177 assert!(
2178 usage_message(run(with_agent, name, &[]))
2179 .starts_with(&format!("`{name}` needs a shell")),
2180 "{name} bare"
2181 );
2182 assert_eq!(
2183 usage_message(run(with_agent, name, &["bash", "zsh"])),
2184 format!("`{name}` takes one shell")
2185 );
2186 }
2187 }
2188 }
2189 }
2190 }
2191
2192 #[test]
2193 fn completions_binds_the_shell_only_in_lower_case() {
2194 assert_eq!(top(&["completions", "zsh"]).unwrap(), Invocation::Completions(Shell::Zsh));
2195 assert_eq!(
2196 usage_message(top(&["completions", "ZSH"])),
2197 "unknown shell `ZSH` (shells: `bash`, `elvish`, `fish`, `powershell`, `zsh`)"
2198 );
2199 let result = split_top_level(CommandWord::Completions, vec![non_utf8()]);
2200 assert!(
2201 usage_message(result).starts_with("unknown shell `"),
2202 "a non-UTF-8 shell must be a usage error"
2203 );
2204 }
2205
2206 #[test]
2208 fn readme_usage_block_carries_every_command_help_line() {
2209 let readme = include_str!("../../../README.md").replace("\r\n", "\n");
2211 let after_usage = readme.split_once("## Usage").expect("README has a Usage section").1;
2212 let block = after_usage
2213 .split_once("```text\n")
2214 .expect("the Usage section has a text block")
2215 .1
2216 .split_once("```")
2217 .expect("the text block is closed")
2218 .0;
2219 let lines: Vec<&str> =
2220 COMMANDS_HELP.lines().filter(|line| line.starts_with(" agent-profile ")).collect();
2221 assert!(
2222 lines.len() >= CommandWord::ALL.len(),
2223 "COMMANDS_HELP lists {} command lines for {} commands; the filter matched too little",
2224 lines.len(),
2225 CommandWord::ALL.len()
2226 );
2227 for line in lines {
2228 let wanted = line.trim_start();
2229 assert!(
2230 block.lines().any(|candidate| candidate == wanted),
2231 "README usage block lacks the line `{wanted}`"
2232 );
2233 }
2234 }
2235}