Skip to main content

vtcode_skills/
command_skills.rs

1use crate::model::{SkillMetadata, SkillScope};
2use crate::types::{SkillContext, SkillManifest, SkillManifestMetadata, SkillVariety};
3use hashbrown::HashMap;
4use serde_json::json;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum BuiltInCommandExecutor {
9    SlashAlias,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum CommandSkillBackend {
14    TraditionalSkill {
15        skill_name: &'static str,
16        skill_path: &'static str,
17    },
18    BuiltInCommand {
19        executor: BuiltInCommandExecutor,
20    },
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct CommandSkillSpec {
25    pub slash_name: &'static str,
26    pub skill_name: &'static str,
27    pub description: &'static str,
28    pub usage: &'static str,
29    category: &'static str,
30    /// Additional slash names that resolve to this command (the canonical name
31    /// stays `slash_name`). Used so legacy commands keep working after a rename.
32    aliases: &'static [&'static str],
33    pub backend: CommandSkillBackend,
34}
35
36impl CommandSkillSpec {
37    /// Category used to group the command in host-specific command surfaces.
38    pub const fn category(self) -> &'static str {
39        self.category
40    }
41
42    const fn is_traditional(self) -> bool {
43        matches!(self.backend, CommandSkillBackend::TraditionalSkill { .. })
44    }
45
46    const fn is_built_in(self) -> bool {
47        matches!(self.backend, CommandSkillBackend::BuiltInCommand { .. })
48    }
49}
50
51#[derive(Debug, Clone)]
52pub struct BuiltInCommandSkill {
53    spec: &'static CommandSkillSpec,
54    manifest: SkillManifest,
55    path: PathBuf,
56}
57
58impl BuiltInCommandSkill {
59    fn from_spec(spec: &'static CommandSkillSpec) -> Self {
60        Self {
61            spec,
62            manifest: built_in_manifest(spec),
63            path: built_in_path(spec.skill_name),
64        }
65    }
66
67    pub fn spec(&self) -> &'static CommandSkillSpec {
68        self.spec
69    }
70
71    pub fn manifest(&self) -> &SkillManifest {
72        &self.manifest
73    }
74
75    pub fn name(&self) -> &str {
76        &self.manifest.name
77    }
78
79    pub fn description(&self) -> &str {
80        &self.manifest.description
81    }
82
83    pub fn usage(&self) -> &'static str {
84        self.spec.usage
85    }
86
87    pub fn category(&self) -> &'static str {
88        self.spec.category
89    }
90
91    pub fn slash_name(&self) -> &'static str {
92        self.spec.slash_name
93    }
94
95    pub fn path(&self) -> &PathBuf {
96        &self.path
97    }
98
99    pub fn scope(&self) -> SkillScope {
100        SkillScope::System
101    }
102
103    pub fn instructions(&self) -> String {
104        format!(
105            "# {}\n\nThis built-in command skill executes the existing `/{}` slash command backend.\n\n- Slash alias: `/{}`
106\n- Usage: `{}`
107\n- Category: `{}`
108\n- Backend: `built_in`\n",
109            self.name(),
110            self.slash_name(),
111            self.slash_name(),
112            self.usage(),
113            self.category()
114        )
115    }
116}
117
118macro_rules! built_in_command_spec {
119    ($slash:literal, $description:literal, $usage:literal, $category:literal) => {
120        CommandSkillSpec {
121            slash_name: $slash,
122            skill_name: concat!("cmd-", $slash),
123            description: $description,
124            usage: $usage,
125            category: $category,
126            aliases: &[],
127            backend: CommandSkillBackend::BuiltInCommand { executor: BuiltInCommandExecutor::SlashAlias },
128        }
129    };
130}
131
132macro_rules! traditional_command_spec {
133    ($slash:literal, $description:literal, $usage:literal, $category:literal, $skill_path:literal) => {
134        CommandSkillSpec {
135            slash_name: $slash,
136            skill_name: concat!("cmd-", $slash),
137            description: $description,
138            usage: $usage,
139            category: $category,
140            aliases: &[],
141            backend: CommandSkillBackend::TraditionalSkill {
142                skill_name: concat!("cmd-", $slash),
143                skill_path: $skill_path,
144            },
145        }
146    };
147}
148
149const COMMAND_SKILL_SPECS: &[CommandSkillSpec] = &[
150    built_in_command_spec!(
151        "init",
152        "Guided workspace setup for vtcode.toml, AGENTS.md, and indexing (usage: /init [--force])",
153        "/init [--force]",
154        "workspace"
155    ),
156    built_in_command_spec!(
157        "config",
158        "Browse settings and run workspace panels: memory, permissions, model, ide, tasks, jobs, log, subprocess, notify, checkup, or reset the active layer (usage: /config [memory|permissions|model|ide|tasks|jobs|log|subprocess|notify|checkup|<path>|reset])",
159        "/config [memory|permissions|model|ide|tasks|jobs|log|subprocess|notify|checkup|<path>|reset]",
160        "configuration"
161    ),
162    CommandSkillSpec {
163        slash_name: "model",
164        skill_name: "cmd-model",
165        description: "Launch the interactive model picker (includes reasoning effort)",
166        usage: "/model (alias: /models)",
167        category: "configuration",
168        aliases: &["models"],
169        backend: CommandSkillBackend::BuiltInCommand { executor: BuiltInCommandExecutor::SlashAlias },
170    },
171    built_in_command_spec!(
172        "fast",
173        "Toggle native OpenAI ultrafast or Claude fast at premium pricing (usage: /fast [on|off])",
174        "/fast [on|off]",
175        "configuration"
176    ),
177    built_in_command_spec!(
178        "mode",
179        "Switch the active agent mode (usage: /mode [agent-name])",
180        "/mode [agent-name]",
181        "configuration"
182    ),
183    built_in_command_spec!("theme", "Switch UI theme (usage: /theme <theme-id>)", "/theme [theme-id]", "configuration"),
184    traditional_command_spec!(
185        "review",
186        "Review the current diff or selected files (usage: /review [instructions | --last-diff | --target <expr> | --file <path> | files...] [--style <style>])",
187        "/review [instructions | --last-diff | --target <expr> | --file <path> | files...] [--style <style>]",
188        "tools",
189        ".system/cmd-review"
190    ),
191    built_in_command_spec!(
192        "files",
193        "Browse and select files from workspace (usage: /files [filter])",
194        "/files [filter]",
195        "tools"
196    ),
197    built_in_command_spec!("copy", "Copy the latest complete assistant reply to clipboard", "/copy", "tools"),
198    built_in_command_spec!(
199        "skills",
200        "Open interactive skills manager (usage: /skills, /skills manager)",
201        "/skills [manager|list|search|create|load|unload|info|use|validate|package|regenerate-index|doctor|help]",
202        "tools"
203    ),
204    built_in_command_spec!(
205        "agent",
206        "Manage subagents and delegated child threads (usage: /agent [list|threads|inspect <id>|close <id>|create [project|user] [name]|edit [name]|delete <name>])",
207        "/agent [list|threads|inspect <id>|close <id>|create [project|user] [name]|edit [name]|delete <name>]",
208        "tools"
209    ),
210    built_in_command_spec!("status", "Show model, provider, workspace, and tool status", "/status", "status"),
211    built_in_command_spec!("splash", "Show CLI, Desktop, and WebMCP connection wiring status", "/splash", "status"),
212    built_in_command_spec!("stop", "Stop the active turn immediately", "/stop", "status"),
213    built_in_command_spec!("pause", "Pause the active turn at the next safe boundary", "/pause", "status"),
214    built_in_command_spec!(
215        "update",
216        "Check for new VT Code releases and install updates (usage: /update [check|install] [--force], or run `vtcode update` from the CLI)",
217        "/update [check|install] [--force]",
218        "status"
219    ),
220    built_in_command_spec!(
221        "mcp",
222        "Open interactive MCP manager (usage: /mcp, optional subcommands still supported)",
223        "/mcp [status|list|tools|refresh|config|config edit|repair|diagnose|login <name>|logout <name>]",
224        "integration"
225    ),
226    built_in_command_spec!(
227        "webmcp",
228        "Manage the opt-in authenticated browser editor bridge (usage: /webmcp [help|status|tools|roots|pair [--replace] <origin>|unpair])",
229        "/webmcp [help|status|tools|roots|pair [--replace] <origin>|unpair]",
230        "integration"
231    ),
232    built_in_command_spec!(
233        "explain",
234        "Explain recorded execution without a model call",
235        "/explain [--scope task|session] [--details|diagram|--web|--export html]",
236        "status"
237    ),
238    built_in_command_spec!(
239        "plugin",
240        "Open interactive Agent Plugins manager (usage: /plugin, /plugin manager)",
241        "/plugin [manager|list|info <name>|add <source> [--name <id>]|remove <name>|validate <path>|refresh|help]",
242        "integration"
243    ),
244    built_in_command_spec!(
245        "local",
246        "Manage local inference servers: Ollama, LM Studio, llama.cpp (usage: /local [action] [provider])",
247        "/local [status|start|stop|configure|troubleshoot] [ollama|lmstudio|llamacpp]",
248        "integration"
249    ),
250    built_in_command_spec!(
251        "resume",
252        "List archived sessions when idle; resume the active turn while it is paused",
253        "/resume [limit|--all]",
254        "session"
255    ),
256    built_in_command_spec!(
257        "fork",
258        "Fork an archived session into a new thread (usage: /fork [limit] [--all])",
259        "/fork [limit] [--all]",
260        "session"
261    ),
262    built_in_command_spec!(
263        "history",
264        "Open command history picker (usage: /history, same as Ctrl+R)",
265        "/history",
266        "session"
267    ),
268    built_in_command_spec!("clear", "Clear visible screen (usage: /clear [new])", "/clear [new]", "session"),
269    built_in_command_spec!(
270        "transcript",
271        "Inspect or manage the bounded TUI transcript snapshot (usage: /transcript [stats|clear|export [path]])",
272        "/transcript [stats|clear|export [path]]",
273        "session"
274    ),
275    built_in_command_spec!(
276        "compact",
277        "Compact the current conversation immediately or manage the saved manual compaction prompt",
278        "/compact [--instructions <text>] [--max-output-tokens <n>] [--reasoning-effort <none|minimal|low|medium|high|xhigh>] [--verbosity <low|medium|high>] [--native-only] | /compact edit-prompt | /compact reset-prompt",
279        "session"
280    ),
281    built_in_command_spec!("new", "Start a new session", "/new", "session"),
282    built_in_command_spec!(
283        "share",
284        "Export the current session as JSON, Markdown, or self-contained HTML timeline (usage: /share [json|markdown|md|html|--format <fmt>])",
285        "/share [json|markdown|md|html|--format <fmt>]",
286        "session"
287    ),
288    built_in_command_spec!(
289        "rewind",
290        "Open the rewind picker or restore a specific checkpoint (usage: /rewind [turn] [conversation|code|both])",
291        "/rewind [turn] [conversation|code|both]",
292        "session"
293    ),
294    built_in_command_spec!("redo", "Restore files and conversation from before the last rewind", "/redo", "session"),
295    built_in_command_spec!(
296        "plan",
297        "Start or continue the planning workflow with an optional task prompt (usage: /plan [task])",
298        "/plan [task]",
299        "session"
300    ),
301    built_in_command_spec!("docs", "Open vtcode documentation in web browser", "/docs", "support"),
302    built_in_command_spec!(
303        "feedback",
304        "Open a new GitHub issue to report a bug or request a feature",
305        "/feedback",
306        "support"
307    ),
308    built_in_command_spec!("help", "Show slash command help", "/help [command]", "support"),
309    built_in_command_spec!("exit", "Exit the session", "/exit", "session"),
310    built_in_command_spec!("donate", "Support the project by buying the author a coffee", "/donate", "support"),
311    built_in_command_spec!(
312        "terminal-setup",
313        "Configure terminal for VT Code (multiline, copy/paste, shell, themes, tab icon)",
314        "/terminal-setup [install-iterm2-icon|reset-iterm2-icon|icon [light|dark|clear|tinted|dynamic]]",
315        "terminal"
316    ),
317    built_in_command_spec!(
318        "statusline",
319        "Set up a custom status line with target selection (usage: /statusline [instructions...])",
320        "/statusline [instructions...]",
321        "terminal"
322    ),
323    built_in_command_spec!("title", "Configure the terminal title items interactively", "/title", "terminal"),
324    built_in_command_spec!(
325        "vim",
326        "Toggle Vim-style prompt editing for this session (usage: /vim [on|off])",
327        "/vim [on|off]",
328        "terminal"
329    ),
330    built_in_command_spec!(
331        "login",
332        "Authenticate with OpenAI, OpenRouter, or GitHub Copilot (usage: /login [provider])",
333        "/login [provider]",
334        "auth"
335    ),
336    built_in_command_spec!(
337        "logout",
338        "Clear stored provider authentication (usage: /logout [provider])",
339        "/logout [provider]",
340        "auth"
341    ),
342    built_in_command_spec!(
343        "auth",
344        "Show authentication status for providers (usage: /auth [provider])",
345        "/auth [provider]",
346        "auth"
347    ),
348    built_in_command_spec!(
349        "refresh-oauth",
350        "Refresh stored provider credentials when supported (usage: /refresh-oauth [provider])",
351        "/refresh-oauth [provider]",
352        "auth"
353    ),
354    built_in_command_spec!(
355        "secret",
356        "Manage provider API keys in your OS keyring (usage: /secret [list|status [provider] [key-name]|add <provider> [key-name]|delete <provider> [key-name]|migrate [provider]|help])",
357        "/secret [list|status [provider] [key-name]|add <provider> [key-name]|delete <provider> [key-name]|migrate [provider]|help]",
358        "auth"
359    ),
360];
361
362pub fn command_skill_specs() -> &'static [CommandSkillSpec] {
363    COMMAND_SKILL_SPECS
364}
365
366pub fn find_command_skill_by_slash_name(name: &str) -> Option<&'static CommandSkillSpec> {
367    COMMAND_SKILL_SPECS
368        .iter()
369        .find(|spec| spec.slash_name == name || spec.aliases.contains(&name))
370}
371
372pub fn find_command_skill_by_skill_name(name: &str) -> Option<&'static CommandSkillSpec> {
373    COMMAND_SKILL_SPECS.iter().find(|spec| spec.skill_name == name)
374}
375
376fn is_command_skill_name(name: &str) -> bool {
377    find_command_skill_by_skill_name(name).is_some()
378}
379
380pub fn is_model_catalog_eligible(skill: &SkillMetadata) -> bool {
381    if skill
382        .manifest
383        .as_ref()
384        .and_then(|manifest| manifest.disable_model_invocation)
385        .unwrap_or(false)
386    {
387        return false;
388    }
389
390    !is_command_skill_name(&skill.name)
391}
392
393pub fn built_in_command_skill_contexts() -> Vec<SkillContext> {
394    COMMAND_SKILL_SPECS
395        .iter()
396        .copied()
397        .filter(|spec| spec.is_built_in())
398        .map(|spec| SkillContext::MetadataOnly(built_in_manifest(&spec), built_in_path(spec.skill_name)))
399        .collect()
400}
401
402pub fn built_in_command_skill(name: &str) -> Option<BuiltInCommandSkill> {
403    find_command_skill_by_skill_name(name)
404        .filter(|spec| spec.is_built_in())
405        .map(BuiltInCommandSkill::from_spec)
406}
407
408pub fn merge_built_in_command_skill_contexts(skills: &mut Vec<SkillContext>) {
409    skills.extend(built_in_command_skill_contexts());
410    skills.sort_by(|left, right| left.manifest().name.cmp(&right.manifest().name));
411    skills.dedup_by(|left, right| left.manifest().name == right.manifest().name);
412}
413
414pub fn merge_built_in_command_skill_metadata(skills: &mut Vec<SkillMetadata>) {
415    skills.extend(built_in_command_skill_contexts().into_iter().map(|skill_ctx| SkillMetadata {
416        name: skill_ctx.manifest().name.clone(),
417        description: skill_ctx.manifest().description.clone(),
418        short_description: None,
419        path: skill_ctx.path().clone(),
420        scope: SkillScope::System,
421        manifest: Some(skill_ctx.manifest().clone().into()),
422    }));
423    skills.sort_by(|left, right| left.name.cmp(&right.name));
424    skills.dedup_by(|left, right| left.name == right.name);
425}
426
427fn built_in_manifest(spec: &CommandSkillSpec) -> SkillManifest {
428    SkillManifest {
429        name: spec.skill_name.to_string(),
430        description: spec.description.to_string(),
431        disable_model_invocation: Some(true),
432        variety: SkillVariety::BuiltIn,
433        metadata: Some(command_skill_metadata(spec, "built_in_command")),
434        ..Default::default()
435    }
436}
437
438fn command_skill_metadata(spec: &CommandSkillSpec, backend: &str) -> SkillManifestMetadata {
439    let mut metadata = HashMap::new();
440    metadata.insert("slash_alias".to_string(), json!(format!("/{}", spec.slash_name)));
441    metadata.insert("usage".to_string(), json!(spec.usage));
442    metadata.insert("category".to_string(), json!(spec.category));
443    metadata.insert("backend".to_string(), json!(backend));
444    metadata
445}
446
447fn built_in_path(skill_name: &str) -> PathBuf {
448    PathBuf::from(format!("<built-in>/{skill_name}"))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn traditional_and_built_in_commands_are_mapped() {
457        let review = find_command_skill_by_slash_name("review").expect("review spec");
458        assert!(review.is_traditional());
459        assert_eq!(review.skill_name, "cmd-review");
460
461        let status = find_command_skill_by_slash_name("status").expect("status spec");
462        assert!(status.is_built_in());
463        assert_eq!(status.skill_name, "cmd-status");
464
465        let splash = find_command_skill_by_slash_name("splash").expect("splash spec");
466        assert!(splash.is_built_in());
467        assert_eq!(splash.usage, "/splash");
468    }
469
470    #[test]
471    fn models_alias_opens_the_model_picker() {
472        let model = find_command_skill_by_slash_name("models").expect("models alias");
473        assert_eq!(model.slash_name, "model");
474        assert!(model.is_built_in());
475    }
476
477    #[test]
478    fn built_in_contexts_are_tagged_correctly() {
479        let built_in = built_in_command_skill_contexts();
480        let status = built_in
481            .iter()
482            .find(|ctx| ctx.manifest().name == "cmd-status")
483            .expect("cmd-status context");
484        assert_eq!(status.manifest().variety, SkillVariety::BuiltIn);
485    }
486
487    #[test]
488    fn removed_generate_agent_file_command_is_not_registered() {
489        assert!(find_command_skill_by_slash_name("generate-agent-file").is_none());
490        assert!(find_command_skill_by_skill_name("cmd-generate-agent-file").is_none());
491    }
492
493    #[test]
494    fn mode_command_switches_active_agent_mode() {
495        // The `/mode` command was re-introduced (commit ef1b52b6f) as a live
496        // feature: with no argument it opens the mode palette, with an argument
497        // (build|auto|duck|plan) it selects the primary agent directly. It must
498        // remain registered alongside the dedicated mode commands.
499        let mode = find_command_skill_by_slash_name("mode").expect("mode spec");
500        assert!(mode.description.contains("mode"));
501    }
502
503    #[test]
504    fn plan_command_skill_uses_workflow_copy() {
505        let plan = find_command_skill_by_slash_name("plan").expect("plan spec");
506        assert!(plan.description.contains("planning workflow"));
507        assert!(!plan.description.contains("mode"));
508        assert_eq!(plan.usage, "/plan [task]");
509    }
510
511    #[test]
512    fn removed_config_consolidated_commands_are_not_registered() {
513        for removed in [
514            "checkup",
515            "doctor",
516            "permissions",
517            "ide",
518            "tasks",
519            "jobs",
520            "log",
521            "subprocess",
522            "notify",
523            "memory",
524            "effort",
525            "continue",
526            "edit",
527            "analyze",
528        ] {
529            assert!(
530                find_command_skill_by_slash_name(removed).is_none(),
531                "/{removed} must not be registered; use /config instead"
532            );
533        }
534        let config = find_command_skill_by_slash_name("config").expect("config spec");
535        assert!(config.usage.contains("checkup"));
536    }
537}