Skip to main content

opendev_repl/commands/
builtin.rs

1//! Built-in slash commands: /help, /clear, /mode, /exit, etc.
2//!
3//! Mirrors `opendev/repl/repl.py::_handle_command`.
4
5use opendev_config::ModelRegistry;
6use opendev_runtime::AutonomyLevel;
7
8use crate::repl::{OperationMode, ReplState};
9
10/// Outcome of dispatching a slash command.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum CommandOutcome {
13    /// Command was handled successfully.
14    Handled,
15    /// The user wants to exit.
16    Exit,
17    /// Command was not recognized.
18    Unknown,
19}
20
21/// Handles built-in slash commands.
22pub struct BuiltinCommands;
23
24impl BuiltinCommands {
25    /// Create a new command handler.
26    pub fn new() -> Self {
27        Self
28    }
29
30    /// Dispatch a slash command.
31    ///
32    /// Returns the outcome indicating how the REPL should proceed.
33    pub fn dispatch(&self, cmd: &str, args: &str, state: &mut ReplState) -> CommandOutcome {
34        match cmd {
35            "/help" => {
36                self.handle_help(args);
37                CommandOutcome::Handled
38            }
39            "/exit" | "/quit" => CommandOutcome::Exit,
40            "/clear" => {
41                self.handle_clear(state);
42                CommandOutcome::Handled
43            }
44            "/mode" => {
45                self.handle_mode(args, state);
46                CommandOutcome::Handled
47            }
48            "/compact" => {
49                self.handle_compact(state);
50                CommandOutcome::Handled
51            }
52            "/models" => {
53                self.handle_models();
54                CommandOutcome::Handled
55            }
56            "/mcp" => {
57                self.handle_mcp(args);
58                CommandOutcome::Handled
59            }
60            "/agents" => {
61                self.handle_agents(args);
62                CommandOutcome::Handled
63            }
64            "/skills" => {
65                self.handle_skills(args);
66                CommandOutcome::Handled
67            }
68            "/plugins" => {
69                self.handle_plugins(args);
70                CommandOutcome::Handled
71            }
72            "/session-models" => {
73                self.handle_session_models(args);
74                CommandOutcome::Handled
75            }
76            "/autonomy" => {
77                self.handle_autonomy(args, state);
78                CommandOutcome::Handled
79            }
80            "/status" => {
81                self.handle_status(state);
82                CommandOutcome::Handled
83            }
84            "/init" => {
85                self.handle_init(args, state);
86                CommandOutcome::Handled
87            }
88            "/sound" => {
89                self.handle_sound();
90                CommandOutcome::Handled
91            }
92            _ => CommandOutcome::Unknown,
93        }
94    }
95
96    fn handle_help(&self, _args: &str) {
97        println!("Available commands:");
98        println!("  /help                   Show this help message");
99        println!("  /exit, /quit            Exit the REPL");
100        println!("  /clear                  Clear conversation history");
101        println!("  /mode [plan|normal]     Switch operation mode");
102        println!("  /autonomy [manual|semi-auto|auto] Set approval level");
103        println!("  /status                 Show current status");
104        println!("  /compact                Compact conversation context");
105        println!("  /models                 Show model picker (from models.dev registry)");
106        println!("  /mcp <subcommand>       Manage MCP servers");
107        println!("  /agents <args>          Manage agents");
108        println!("  /skills <args>          Manage skills");
109        println!("  /plugins <args>         Manage plugins");
110        println!("  /session-models         Session model management");
111        println!("  /sound                  Play test notification sound");
112        println!("  /init                   Initialize codebase context");
113    }
114
115    fn handle_clear(&self, state: &mut ReplState) {
116        state.messages_cleared = true;
117        println!("Conversation cleared.");
118    }
119
120    fn handle_mode(&self, args: &str, state: &mut ReplState) {
121        let target = args.trim().to_lowercase();
122        match target.as_str() {
123            "plan" => {
124                state.mode = OperationMode::Plan;
125                println!("Switched to Plan mode (read-only tools).");
126            }
127            "normal" | "" => {
128                state.mode = OperationMode::Normal;
129                println!("Switched to Normal mode (full tool access).");
130            }
131            _ => {
132                println!("Usage: /mode [plan|normal]");
133            }
134        }
135    }
136
137    fn handle_compact(&self, state: &mut ReplState) {
138        state.compact_requested = true;
139        println!("Context compaction triggered.");
140    }
141
142    fn handle_models(&self) {
143        self.handle_model_picker(None);
144    }
145
146    /// Display a numbered list of models from the models.dev registry.
147    ///
148    /// If `cache_dir` is `None`, uses the default `~/.opendev/cache` directory.
149    /// Returns the list of `(provider_id, model_id)` pairs for use by callers
150    /// that want to act on the user's selection.
151    pub fn handle_model_picker(
152        &self,
153        cache_dir: Option<&std::path::Path>,
154    ) -> Vec<(String, String)> {
155        let cache = cache_dir.map(std::path::PathBuf::from).unwrap_or_else(|| {
156            dirs::home_dir()
157                .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
158                .join(".opendev")
159                .join("cache")
160        });
161
162        let registry = ModelRegistry::load_from_cache(&cache);
163
164        if registry.providers.is_empty() {
165            println!("No models available. Run `opendev setup` or check network connectivity.");
166            return Vec::new();
167        }
168
169        let models = registry.list_all_models(None, None);
170
171        if models.is_empty() {
172            println!("No models found in registry.");
173            return Vec::new();
174        }
175
176        println!("Available models:");
177        println!();
178
179        let mut entries: Vec<(String, String)> = Vec::new();
180        for (i, (provider_id, model)) in models.iter().enumerate() {
181            let num = i + 1;
182            let pricing = model.format_pricing();
183            let ctx = if model.context_length >= 1_000_000 {
184                format!("{}M ctx", model.context_length / 1_000_000)
185            } else if model.context_length >= 1_000 {
186                format!("{}K ctx", model.context_length / 1_000)
187            } else {
188                format!("{} ctx", model.context_length)
189            };
190            let caps = if model.capabilities.is_empty() {
191                String::new()
192            } else {
193                format!(" [{}]", model.capabilities.join(", "))
194            };
195
196            println!(
197                "  {num:>3}. {name} ({provider}) - {ctx}, {pricing}{caps}",
198                name = model.name,
199                provider = model.provider,
200            );
201
202            entries.push((provider_id.to_string(), model.id.clone()));
203        }
204
205        println!();
206        println!("Use /session-models set model <name> to change the active model.");
207
208        entries
209    }
210
211    fn handle_mcp(&self, args: &str) {
212        let parts: Vec<&str> = args.trim().splitn(2, ' ').collect();
213        let subcommand = parts.first().copied().unwrap_or("");
214        let sub_args = parts.get(1).copied().unwrap_or("");
215
216        match subcommand {
217            "" | "list" => {
218                println!("MCP Servers:");
219                println!("  (none configured)");
220                println!();
221                println!("Use /mcp add <name> <command> to register a server.");
222            }
223            "add" => {
224                if sub_args.is_empty() {
225                    println!("Usage: /mcp add <name> <command> [args...]");
226                } else {
227                    let name = sub_args.split_whitespace().next().unwrap_or(sub_args);
228                    println!(
229                        "MCP server '{}' registered (restart required to activate).",
230                        name
231                    );
232                }
233            }
234            "remove" => {
235                if sub_args.is_empty() {
236                    println!("Usage: /mcp remove <name>");
237                } else {
238                    println!("MCP server '{}' removed.", sub_args.trim());
239                }
240            }
241            "enable" => {
242                if sub_args.is_empty() {
243                    println!("Usage: /mcp enable <name>");
244                } else {
245                    println!("MCP server '{}' enabled.", sub_args.trim());
246                }
247            }
248            "disable" => {
249                if sub_args.is_empty() {
250                    println!("Usage: /mcp disable <name>");
251                } else {
252                    println!("MCP server '{}' disabled.", sub_args.trim());
253                }
254            }
255            _ => {
256                println!("Unknown MCP subcommand: {}", subcommand);
257                println!("Usage: /mcp [list|add|remove|enable|disable] ...");
258            }
259        }
260    }
261
262    fn handle_agents(&self, args: &str) {
263        let subcommand = args.split_whitespace().next().unwrap_or("list");
264        match subcommand {
265            "list" | "" => {
266                println!("Available agents:");
267                println!("  - Explore          Explore and understand codebase structure");
268                println!("  - Planner          Create and refine implementation plans");
269                println!("  - Ask-User         Request clarification from the user");
270            }
271            _ => {
272                println!("Usage: /agents [list]");
273            }
274        }
275    }
276
277    fn handle_skills(&self, args: &str) {
278        let subcommand = args.split_whitespace().next().unwrap_or("list");
279        match subcommand {
280            "list" | "" => {
281                println!("Built-in skills:");
282                println!("  - commit       Git commit best practices");
283                println!("  - review-pr    Pull request review guidelines");
284                println!("  - create-pr    Pull request creation workflow");
285                println!();
286                println!("Use /skills to invoke a skill by name.");
287            }
288            _ => {
289                println!("Usage: /skills [list]");
290            }
291        }
292    }
293
294    fn handle_plugins(&self, args: &str) {
295        let parts: Vec<&str> = args.trim().splitn(2, ' ').collect();
296        let subcommand = parts.first().copied().unwrap_or("");
297        let sub_args = parts.get(1).copied().unwrap_or("");
298
299        match subcommand {
300            "" | "list" => {
301                println!("Plugins: (none installed)");
302                println!("Use /plugins install <name> to add plugins.");
303            }
304            "install" => {
305                if sub_args.is_empty() {
306                    println!("Usage: /plugins install <name>");
307                } else {
308                    println!("Installing plugin '{}'...", sub_args.trim());
309                    println!("Plugin installation not yet connected to marketplace.");
310                }
311            }
312            "remove" => {
313                if sub_args.is_empty() {
314                    println!("Usage: /plugins remove <name>");
315                } else {
316                    println!(
317                        "Plugin '{}' not found in installed plugins.",
318                        sub_args.trim()
319                    );
320                }
321            }
322            _ => {
323                println!("Unknown plugins subcommand: {}", subcommand);
324                println!("Usage: /plugins [list|install|remove] ...");
325            }
326        }
327    }
328
329    fn handle_session_models(&self, args: &str) {
330        let parts: Vec<&str> = args.trim().splitn(2, ' ').collect();
331        let subcommand = parts.first().copied().unwrap_or("");
332        let sub_args = parts.get(1).copied().unwrap_or("");
333
334        match subcommand {
335            "" | "show" => {
336                println!("No session model overrides set.");
337                println!();
338                println!("Available slots: model, model_vlm");
339                println!(
340                    "Use /session-models set <slot> <value> to override a model for this session."
341                );
342            }
343            "set" => {
344                let set_parts: Vec<&str> = sub_args.splitn(2, ' ').collect();
345                if set_parts.len() < 2 {
346                    println!("Usage: /session-models set <slot> <model-name>");
347                } else {
348                    let slot = set_parts[0];
349                    let value = set_parts[1];
350                    let valid_slots =
351                        ["model", "model_provider", "model_vlm", "model_vlm_provider"];
352                    if valid_slots.contains(&slot) {
353                        println!("Session override: {} = {}", slot, value);
354                    } else {
355                        println!("Unknown slot: {}", slot);
356                        println!("Valid slots: {}", valid_slots.join(", "));
357                    }
358                }
359            }
360            "clear" => {
361                println!("Session model overrides cleared.");
362            }
363            _ => {
364                println!("Unknown session-models subcommand: {}", subcommand);
365                println!("Usage: /session-models [show|set|clear] ...");
366            }
367        }
368    }
369
370    fn handle_autonomy(&self, args: &str, state: &mut ReplState) {
371        let target = args.trim();
372        if target.is_empty() {
373            println!("Autonomy level: {}", state.autonomy_level);
374            println!("Usage: /autonomy [manual|semi-auto|auto]");
375            return;
376        }
377        match AutonomyLevel::from_str_loose(target) {
378            Some(level) => {
379                state.autonomy_level = level;
380                let detail = match level {
381                    AutonomyLevel::Manual => "(all commands require approval)",
382                    AutonomyLevel::SemiAuto => "(safe commands auto-approved)",
383                    AutonomyLevel::Auto => "(all commands auto-approved)",
384                };
385                println!("Autonomy level set to: {} {}", level, detail);
386            }
387            None => {
388                println!("Invalid autonomy level: {}", target);
389                println!("Valid levels: manual, semi-auto, auto");
390            }
391        }
392    }
393
394    fn handle_status(&self, state: &ReplState) {
395        println!("Current status:");
396        println!("  Mode:      {}", state.mode);
397        println!("  Autonomy:  {}", state.autonomy_level);
398    }
399
400    fn handle_sound(&self) {
401        opendev_runtime::play_finish_sound();
402        println!("Playing test sound...");
403    }
404
405    fn handle_init(&self, args: &str, state: &mut ReplState) {
406        let prompt = opendev_agents::prompts::embedded::build_init_prompt(args);
407        state.init_prompt = Some(prompt);
408        println!("Generating AGENTS.md...");
409    }
410}
411
412impl Default for BuiltinCommands {
413    fn default() -> Self {
414        Self::new()
415    }
416}
417
418#[cfg(test)]
419#[path = "builtin_tests.rs"]
420mod tests;