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

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. 

14 

15"""Slash command executor for template expansion and validation. 

16 

17This module handles parsing user input, building context from settings 

18and arguments, validating required arguments, and expanding templates. 

19""" 

20 

21import re 

22from typing import TYPE_CHECKING 

23 

24from agent.commands.manifest import SlashCommandManifest 

25from agent.commands.registry import SlashCommandRegistry 

26 

27if TYPE_CHECKING: 

28 from agent.config.schema import AgentSettings 

29 

30 

31class SlashCommandExecutor: 

32 """Execute slash commands by parsing args and expanding templates. 

33 

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 

39 

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 """ 

48 

49 def __init__(self, settings: "AgentSettings", registry: SlashCommandRegistry): 

50 """Initialize executor. 

51 

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 

59 

60 def parse_input(self, user_input: str) -> tuple[str, list[str]] | None: 

61 """Parse slash command input into (command_name, args). 

62 

63 Args: 

64 user_input: Raw user input (e.g., "/clone core") 

65 

66 Returns: 

67 Tuple of (command_name, args_list) or None if not a slash command. 

68 Command name is without the leading /. 

69 

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() 

77 

78 if not user_input.startswith("/"): 

79 return None 

80 

81 # Split on whitespace, first part is command 

82 parts = user_input[1:].split() 

83 

84 if not parts: 

85 return None 

86 

87 return parts[0], parts[1:] 

88 

89 def build_context(self, args: list[str]) -> dict[str, str]: 

90 """Build template context from args and settings. 

91 

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 

96 

97 Args: 

98 args: List of command arguments 

99 

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" 

107 

108 context: dict[str, str] = { 

109 "repos_root": str(repos_root), 

110 "args": " ".join(args), 

111 } 

112 

113 # Add positional args (arg0, arg1, etc.) 

114 for i, arg in enumerate(args): 

115 context[f"arg{i}"] = arg 

116 

117 return context 

118 

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. 

123 

124 Args: 

125 command: Command manifest with required_args list 

126 context: Context dictionary with arg values 

127 

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 

136 

137 def expand_template(self, template: str, context: dict[str, str]) -> str: 

138 """Expand template placeholders with context values. 

139 

140 Uses regex to safely expand {{variable}} patterns, handling 

141 cases like {{arg1}} vs {{arg10}} correctly (whole-word match). 

142 

143 Unmatched placeholders are left as-is in the output. 

144 

145 Args: 

146 template: Template string with {{variable}} placeholders 

147 context: Dictionary of variable names to values 

148 

149 Returns: 

150 Expanded template string 

151 

152 Example: 

153 >>> expand_template("Clone {{arg0}} to {{repos_root}}", {"arg0": "core", "repos_root": "/tmp"}) 

154 'Clone core to /tmp' 

155 """ 

156 

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) 

163 

164 # Match {{word}} patterns - \w+ ensures whole-word match 

165 # This handles {{arg1}} vs {{arg10}} correctly 

166 return re.sub(r"\{\{(\w+)\}\}", replacer, template) 

167 

168 def get_usage_message(self, command: SlashCommandManifest) -> str: 

169 """Generate usage message for a command. 

170 

171 Args: 

172 command: Command manifest 

173 

174 Returns: 

175 Formatted usage message with examples 

176 """ 

177 parts = [f"Usage: /{command.name}"] 

178 

179 if command.args: 

180 parts.append(command.args) 

181 

182 message = " ".join(parts) 

183 

184 if command.examples: 

185 message += f"\nExample: {command.examples[0]}" 

186 

187 return message