Skip to main content

vtcode_desktop_protocol/
slash_command.rs

1//! Shared slash-command grammar used by the CLI and desktop client.
2
3/// Human-readable catalog of the operations exposed by the WebMCP bridge.
4pub const WEBMCP_TOOLS_HELP: &str = "WebMCP tools: workspace.list_files, workspace.read_file, patch.propose, patch.apply, checks.run, patch.revert, turn.request, cancel";
5
6/// Configuration guidance for standalone headless WebMCP workspace roots.
7pub const WEBMCP_ROOTS_HELP: &str =
8    "Headless WebMCP roots are configured with [webmcp].allowed_roots or --allowed-root.";
9
10/// A parsed `/webmcp` slash command.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum WebmcpSlashCommand {
13    /// Show the active WebMCP bridge status.
14    Status,
15    /// Show WebMCP command help.
16    Help,
17    /// List the supported WebMCP tools.
18    Tools,
19    /// Describe the configured headless WebMCP workspace roots.
20    Roots,
21    /// Show usage for the `connect` or `pair` command.
22    ConnectHelp,
23    /// Pair a browser origin.
24    Connect {
25        /// Exact browser origin.
26        origin: String,
27        /// Revoke existing pairings before issuing a new pairing code.
28        replace: bool,
29    },
30    /// Stop the bridge or revoke its in-memory pairings.
31    Unpair,
32}
33
34/// Why a WebMCP slash command could not be parsed.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum WebmcpSlashCommandParseError {
37    /// The verb is not a recognized WebMCP command or has unsupported trailing arguments.
38    UnknownCommand,
39    /// A `connect` or `pair` command has malformed origin or replacement arguments.
40    InvalidConnectionArguments,
41}
42
43/// Parse the arguments written after `/webmcp`.
44///
45/// The empty argument string and `status` both select the status command. `pair` aliases `connect`,
46/// and `stop` aliases `unpair` so both frontends accept the same command vocabulary.
47///
48/// # Errors
49/// Returns [`WebmcpSlashCommandParseError::UnknownCommand`] for an unsupported verb and
50/// [`WebmcpSlashCommandParseError::InvalidConnectionArguments`] for malformed pairing options.
51pub fn parse_webmcp_slash_command(arguments: &str) -> Result<WebmcpSlashCommand, WebmcpSlashCommandParseError> {
52    let arguments = arguments.trim();
53    if arguments.is_empty() {
54        return Ok(WebmcpSlashCommand::Status);
55    }
56
57    let (verb, tail) = arguments
58        .split_once(char::is_whitespace)
59        .map_or((arguments, ""), |(verb, tail)| (verb, tail.trim()));
60
61    match verb {
62        "status" if tail.is_empty() => Ok(WebmcpSlashCommand::Status),
63        "help" if tail.is_empty() => Ok(WebmcpSlashCommand::Help),
64        "tools" if tail.is_empty() => Ok(WebmcpSlashCommand::Tools),
65        "roots" if tail.is_empty() => Ok(WebmcpSlashCommand::Roots),
66        "unpair" | "stop" if tail.is_empty() => Ok(WebmcpSlashCommand::Unpair),
67        "connect" | "pair" => parse_connection_arguments(tail),
68        _ => Err(WebmcpSlashCommandParseError::UnknownCommand),
69    }
70}
71
72fn parse_connection_arguments(arguments: &str) -> Result<WebmcpSlashCommand, WebmcpSlashCommandParseError> {
73    if arguments.is_empty() {
74        return Ok(WebmcpSlashCommand::ConnectHelp);
75    }
76
77    let mut tokens = arguments.split_whitespace();
78    let mut origin = None;
79    let mut replace = false;
80
81    while let Some(token) = tokens.next() {
82        match token {
83            "--replace" if !replace => replace = true,
84            "--origin" if origin.is_none() => {
85                let Some(value) = tokens.next() else {
86                    return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
87                };
88                if value.starts_with('-') {
89                    return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
90                }
91                origin = Some(value);
92            }
93            value if origin.is_none() && !value.starts_with('-') => origin = Some(value),
94            _ => return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments),
95        }
96    }
97
98    let Some(origin) = origin else {
99        return Err(WebmcpSlashCommandParseError::InvalidConnectionArguments);
100    };
101
102    Ok(WebmcpSlashCommand::Connect { origin: origin.to_owned(), replace })
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn parses_status_help_and_webmcp_catalog_commands() {
111        assert_eq!(parse_webmcp_slash_command(""), Ok(WebmcpSlashCommand::Status));
112        assert_eq!(parse_webmcp_slash_command(" status "), Ok(WebmcpSlashCommand::Status));
113        assert_eq!(parse_webmcp_slash_command("help"), Ok(WebmcpSlashCommand::Help));
114        assert_eq!(parse_webmcp_slash_command("tools"), Ok(WebmcpSlashCommand::Tools));
115        assert_eq!(parse_webmcp_slash_command("roots"), Ok(WebmcpSlashCommand::Roots));
116    }
117
118    #[test]
119    fn parses_connect_aliases_and_options_in_either_order() {
120        assert_eq!(
121            parse_webmcp_slash_command("connect https://example.test"),
122            Ok(WebmcpSlashCommand::Connect {
123                origin: "https://example.test".into(),
124                replace: false,
125            })
126        );
127        assert_eq!(
128            parse_webmcp_slash_command("pair --replace --origin http://localhost:5173"),
129            Ok(WebmcpSlashCommand::Connect {
130                origin: "http://localhost:5173".into(),
131                replace: true,
132            })
133        );
134        assert_eq!(
135            parse_webmcp_slash_command("connect --origin http://localhost:5173 --replace"),
136            Ok(WebmcpSlashCommand::Connect {
137                origin: "http://localhost:5173".into(),
138                replace: true,
139            })
140        );
141        assert_eq!(parse_webmcp_slash_command("pair"), Ok(WebmcpSlashCommand::ConnectHelp));
142    }
143
144    #[test]
145    fn parses_both_disconnect_verbs() {
146        assert_eq!(parse_webmcp_slash_command("unpair"), Ok(WebmcpSlashCommand::Unpair));
147        assert_eq!(parse_webmcp_slash_command("stop"), Ok(WebmcpSlashCommand::Unpair));
148    }
149
150    #[test]
151    fn rejects_ambiguous_or_malformed_commands() {
152        for input in [
153            "status extra",
154            "unknown",
155            "pair https://a.test https://b.test",
156            "pair --unknown https://a.test",
157            "pair --replace --replace https://a.test",
158            "pair --origin --replace",
159            "connect --replace",
160        ] {
161            assert!(parse_webmcp_slash_command(input).is_err(), "accepted {input:?}");
162        }
163    }
164}