Skip to main content

agent_profile/
cli.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//! CLI grammar: launch syntax, wrapper options and reserved command words (spec §5; SP1 design §4) and the
8//! repository commands (SP3 design §7).
9
10use 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/// Select and launch profiles for multiple coding agents.
68#[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/// Every reserved word — the eight of spec §5.3 plus `resolve`, `current`, `repositories`, `doctor` and
84/// `completions` — is dispatched before Clap (SP3 design §7.2), so Clap sees only the agent form.
85#[derive(Subcommand)]
86enum Command {
87    #[command(external_subcommand)]
88    Agent(Vec<OsString>),
89}
90
91/// The repository commands (SP3 design §7.1).
92#[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/// Where a command word may appear: with an agent, without one, or both.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum AgentScope {
112    TopLevelOnly,
113    AgentRequired,
114    Either,
115}
116
117/// The positional words a command takes after its command word.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119enum Positional {
120    None,
121    RequiredProfile,
122    OptionalProfile,
123    Shell,
124}
125
126impl CommandWord {
127    /// The exact lower-case spelling only.
128    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    /// The command's help text (SP3 design §7.3).
273    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
348/// Runs the CLI and returns the process exit code. This is the only place an exit code is decided.
349pub fn run(args: impl IntoIterator<Item = OsString>) -> i32 {
350    let args: Vec<OsString> = args.into_iter().collect();
351    // Clap drops a leading `--` from a subcommand's trailing arguments, so the repository commands are
352    // dispatched on the raw first argument (SP3 design §7.2).
353    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/// The outcome of the argument checks (SP1 design §4.2, SP3 design §7.2).
384#[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/// One token before the first `--`, after `--repo` binding (SP3 design §7.2 step 1).
408#[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
419/// Splits at the first `--`: the tokens before it, and the opaque arguments when there is one.
420fn 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
427/// Step 1: `--repo=<v>` carries its value; a bare `--repo` consumes the next token whatever it is.
428fn 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/// The value of a `--repo=<v>` token, which may be non-UTF-8.
449#[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/// The value of a `--repo=<v>` token, which may be non-UTF-8.
459#[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
492/// Only the part before `=` is echoed: the value may be a secret meant for the agent (spec §36).
493fn 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
498/// `agent-profile <command> …` for the reserved words dispatched before Clap (SP3 design §7.2).
499fn 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
520/// `agent-profile <agent> …`: SP1 design §4.2 rules 3-4 as replaced by SP3 design §7.2.
521fn 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    // 2. Help and version.
530    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    // 3. Unknown agent (exact, case-sensitive).
540    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    // 4. A reserved first bare word.
551    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    // 6. A launch. A `--repo` binding is an unknown option here.
566    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
604/// Step 5: `bare` holds the bare words after the command word.
605fn 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    // `agents`, `profiles` and `repositories` are top-level only (SP5a design §5, SP5b design §4).
617    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    // The lifecycle words take no `--repo`; refused before the checks below so the message names the command.
628    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    // `--yes` is exact and `delete`'s alone: `--yes=1` stays an unknown option, matched like `--dry-run=yes`
647    // is (SP5a design §5).
648    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    // `link`, `create` and `delete` take exactly one profile word (SP3 design §7.2, SP5a design §5).
655    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/// Whether a launch runs discovery (SP3 design §7.6).
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734enum LaunchDiscovery {
735    /// No profile word: discovery selects the profile, and its errors exit 4.
736    Required,
737    /// An explicit profile with a report: discovery only fills the report, and its errors are ignored.
738    ReportOnly,
739    /// An explicit profile without a report: nothing is discovered.
740    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
756/// The directory a `--repo` value is joined to. An absolute `--repo` needs no current directory, so a command
757/// given one still works from a working directory that no longer exists (design §6.2, §10 "`--repo` is the
758/// explicit override").
759fn 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    // SP1 design §5.1 steps 2-4, SP3 design §7.6.
789    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(&current_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    // SP2 design §4.3 step 1: parsing already refused unknown agents.
808    let adapter = adapter::lookup(agent.as_str()).expect("known agents have an adapter");
809    // Step 2: a pure conflict scan, before executable discovery.
810    adapter::check_conflicts(adapter.metadata(), &opaque)?;
811    // Step 3: discovery, the case-only-twin check, the plan.
812    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    // Step 4.
825    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    // Step 5. The verbose report is rendered after initialization, so it never says "(would be created)".
833    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        // SP4 design §7.2: the only disclosure on the silent path. Suppressed under `--verbose`, which just
842        // printed the whole matrix, and never reached on a dry run, which returned above.
843        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
853/// SP3 design §7.4 steps 2-5.
854fn 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    // The load above refuses an invalid configuration but creates nothing, so it runs before anything
880    // is touched (V3 §17). Neither lifecycle command needs repository discovery, so both return before
881    // the repository work below: `create` does not use the configuration at all, while `delete` reads it
882    // for its referenced-profile checks and `lifecycle::delete` re-reads it under the lock.
883    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
1002/// `profiles` and bare `list` and `<agent> list` (SP5a design §3): the read-only listings. Nothing is
1003/// created — `run_command` has already loaded the configuration (V3 §17) but no root, no `config.toml`
1004/// and no lock is written here — and every outcome exits 0: an empty listing is the answer, not an
1005/// error, and unrecognized on-disk entries are a stderr note rather than a failure.
1006fn 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
1035/// `repositories` (SP5b design §4): every stored mapping with its classification. Read-only, takes no lock,
1036/// and always exits 0: an entry that cannot be examined is reported, not raised.
1037fn 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
1047/// `agents` (SP5a design §3): every registry entry with its executable evidence. Read-only — the load in
1048/// `run_command` refuses an invalid configuration but nothing is created here (V3 §17) — and it always
1049/// exits 0: a missing or unusable executable is listed as a fact, not an error, while any discovery error
1050/// that is not one of those facts propagates.
1051fn 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
1072/// `<agent> create <profile>` (SP5a design §3): materializes the adapter's paths exactly once. Both
1073/// outcomes exit 0; the configuration was already refused if invalid by `run_command`'s load, and is
1074/// otherwise unused.
1075fn 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
1082/// Asks the user a yes/no question: prints `question` to stderr without a newline, flushes, and reads one
1083/// line. Only `y` or `yes`, trimmed and ignoring ASCII case, counts; EOF or anything else is no.
1084fn 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
1098/// `<agent> delete <profile> [--yes]` (SP5a design §3). An advisory referenced-profile check first, then
1099/// `--yes` or an interactive confirmation, then the locked `lifecycle::delete`. A missing profile exits 0
1100/// rather than erroring, because that is the state the user asked for — the same line and code as a
1101/// profile that vanishes between the check and the lock. The configuration was already refused if invalid
1102/// by `run_command`'s load, and is read again under the lock inside `lifecycle::delete`.
1103fn 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            // A profile whose directories are gone can still be blocked by a mapping, which the user
1115            // cannot delete and must unlink first (SP5a design §3).
1116            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            // The advisory refusal: broader than the invariant needs, because removal is irreversible
1127            // and a mapping blocks after the deletion too. The same check runs again inside the lock,
1128            // where it is authoritative.
1129            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
1161/// `unlink` chooses its keys without discovery when `--repo` is given (SP3 design §6.2).
1162fn 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
1186/// SP5c design §3-§4: every check runs and prints; the exit code is 5 when any record failed. A working
1187/// directory that cannot be read is not an error here: discovering `.` then yields the `repository` record.
1188fn 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
1236/// Fills `unknown_configured` on `UnknownAgent` and `AgentNotInstalled`. With no configuration at hand,
1237/// one best-effort load is attempted; its failure leaves the list empty (design §4.2 check 3).
1238fn 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    /// Runs the top-level dispatch the way `run` does: `items[0]` is the command word.
1316    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    /// Every `--repo` spelling must name the same target whether it is joined to the base `base_dir` chose or to
1699    /// the working directory; only then is skipping `current_dir()` invisible.
1700    #[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            // `join` discards the base for an absolute value, so the equality below holds whatever `base_dir`
1723            // returned. This is the discriminating assertion: the working directory is skipped exactly for the
1724            // spellings that do not need it.
1725            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        // word          scope          repo   json   yes    top_level                  with_agent
1892        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            // Bare, top level.
2055            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            // Bare, with an agent.
2075            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            // Flags, on whichever form is valid.
2091            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            // Positional arity, on each valid form.
2138            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    /// The README's usage block must carry every `COMMANDS_HELP` command line, so the two cannot drift.
2207    #[test]
2208    fn readme_usage_block_carries_every_command_help_line() {
2209        // A Windows checkout may rewrite the README with CRLF line endings.
2210        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}