Coverage for src / agent / commands / executor.py: 100%
45 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
1# Copyright 2025-2026 Microsoft Corporation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Slash command executor for template expansion and validation.
17This module handles parsing user input, building context from settings
18and arguments, validating required arguments, and expanding templates.
19"""
21import re
22from typing import TYPE_CHECKING
24from agent.commands.manifest import SlashCommandManifest
25from agent.commands.registry import SlashCommandRegistry
27if TYPE_CHECKING:
28 from agent.config.schema import AgentSettings
31class SlashCommandExecutor:
32 """Execute slash commands by parsing args and expanding templates.
34 Handles the mechanics of:
35 - Parsing user input into command name and arguments
36 - Building context from settings and positional arguments
37 - Validating required arguments are present
38 - Expanding {{variable}} placeholders in templates
40 Example:
41 >>> executor = SlashCommandExecutor(settings, registry)
42 >>> result = executor.parse_input("/clone core")
43 >>> if result:
44 ... name, args = result
45 ... context = executor.build_context(args)
46 ... prompt = executor.expand_template(cmd.prompt_template, context)
47 """
49 def __init__(self, settings: "AgentSettings", registry: SlashCommandRegistry):
50 """Initialize executor.
52 Args:
53 settings: Agent settings for repos_root and other context
54 registry: Command registry for lookups (not currently used but
55 available for future features like command aliases)
56 """
57 self.settings = settings
58 self.registry = registry
60 def parse_input(self, user_input: str) -> tuple[str, list[str]] | None:
61 """Parse slash command input into (command_name, args).
63 Args:
64 user_input: Raw user input (e.g., "/clone core")
66 Returns:
67 Tuple of (command_name, args_list) or None if not a slash command.
68 Command name is without the leading /.
70 Example:
71 >>> executor.parse_input("/clone core domain")
72 ('clone', ['core', 'domain'])
73 >>> executor.parse_input("hello")
74 None
75 """
76 user_input = user_input.strip()
78 if not user_input.startswith("/"):
79 return None
81 # Split on whitespace, first part is command
82 parts = user_input[1:].split()
84 if not parts:
85 return None
87 return parts[0], parts[1:]
89 def build_context(self, args: list[str]) -> dict[str, str]:
90 """Build template context from args and settings.
92 Creates a context dictionary with:
93 - repos_root: From settings or default ~/.osdu-agent/repos
94 - args: All arguments as space-separated string
95 - arg0, arg1, ...: Individual positional arguments
97 Args:
98 args: List of command arguments
100 Returns:
101 Context dictionary for template expansion
102 """
103 # Get repos_root, defaulting to ~/.osdu-agent/repos
104 repos_root = self.settings.repos_root
105 if repos_root is None:
106 repos_root = self.settings.agent_data_dir / "repos"
108 context: dict[str, str] = {
109 "repos_root": str(repos_root),
110 "args": " ".join(args),
111 }
113 # Add positional args (arg0, arg1, etc.)
114 for i, arg in enumerate(args):
115 context[f"arg{i}"] = arg
117 return context
119 def validate_required_args(
120 self, command: SlashCommandManifest, context: dict[str, str]
121 ) -> list[str]:
122 """Validate that all required args are present in context.
124 Args:
125 command: Command manifest with required_args list
126 context: Context dictionary with arg values
128 Returns:
129 List of missing argument names (empty if all present)
130 """
131 missing = []
132 for arg in command.required_args:
133 if arg not in context or not context[arg]:
134 missing.append(arg)
135 return missing
137 def expand_template(self, template: str, context: dict[str, str]) -> str:
138 """Expand template placeholders with context values.
140 Uses regex to safely expand {{variable}} patterns, handling
141 cases like {{arg1}} vs {{arg10}} correctly (whole-word match).
143 Unmatched placeholders are left as-is in the output.
145 Args:
146 template: Template string with {{variable}} placeholders
147 context: Dictionary of variable names to values
149 Returns:
150 Expanded template string
152 Example:
153 >>> expand_template("Clone {{arg0}} to {{repos_root}}", {"arg0": "core", "repos_root": "/tmp"})
154 'Clone core to /tmp'
155 """
157 def replacer(match: re.Match[str]) -> str:
158 key = match.group(1)
159 if key in context:
160 return context[key]
161 # Leave unmatched placeholders as-is
162 return match.group(0)
164 # Match {{word}} patterns - \w+ ensures whole-word match
165 # This handles {{arg1}} vs {{arg10}} correctly
166 return re.sub(r"\{\{(\w+)\}\}", replacer, template)
168 def get_usage_message(self, command: SlashCommandManifest) -> str:
169 """Generate usage message for a command.
171 Args:
172 command: Command manifest
174 Returns:
175 Formatted usage message with examples
176 """
177 parts = [f"Usage: /{command.name}"]
179 if command.args:
180 parts.append(command.args)
182 message = " ".join(parts)
184 if command.examples:
185 message += f"\nExample: {command.examples[0]}"
187 return message