Coverage for src / osiris_cli / commands.py: 0%

254 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-27 17:41 +0200

1from typing import Any 

2import json 

3from datetime import datetime 

4from pathlib import Path 

5import os 

6import asyncio 

7import shutil 

8 

9from .config import settings 

10from .context import context 

11from .session import session 

12from .client import async_client, client 

13from .tools import tools 

14from .mcp import mcp_client 

15from .config_wizard import ConfigWizard 

16from .session_view import SessionManageScreen 

17from .recipe_view import RecipeScreen 

18from .file_picker import FilePickerScreen 

19from .context_view import FileActionScreen 

20from .prompt_view import PromptScreen 

21from .search_view import SearchScreen 

22from .persona_view import PersonaScreen 

23from .lsp_view import LSPConfigScreen 

24from .help_screen import HelpScreen 

25from .screens import ( 

26 SettingsScreen, ToolsScreen, MCPSettingsScreen, ModelMatrix 

27) 

28from .ui import ( 

29 VoidSessions, VoidSettings, VoidPrompts, 

30 VoidRecipes, VoidSearch, VoidFilePicker, VoidHelp, 

31 VoidModels 

32) 

33from textual.widgets import Label 

34from .header import OsirisHeader 

35 

36class CommandHandler: 

37 def __init__(self, app: Any): 

38 self.app = app 

39 

40 async def handle(self, cmd_str: str): 

41 try: 

42 parts = cmd_str.split() 

43 if not parts: return 

44 cmd = parts[0].lower() 

45 args = parts[1:] 

46 

47 if cmd in ("/new", "/clear"): 

48 self.app.query_one("#chat-scroll").remove_children() 

49 context.clear() 

50 session.clear() 

51 await self.app.update_context_tree() 

52 

53 # Reset Header Stats 

54 try: 

55 self.app.query_one(OsirisHeader).update_tokens(0) 

56 except Exception: pass 

57 

58 await self.app.log_agent("Session Reset.", "success") 

59 

60 await self.app.add_chat_message("Session Reset.", "assistant") 

61 elif cmd == "/quit": 

62 self.app.exit() 

63 elif cmd == "/undo": 

64 if session.undo_last_step(): 

65 # Refresh chat view 

66 self.app.query_one("#chat-scroll").remove_children() 

67 for msg in session.messages: 

68 role = msg.get("role") 

69 content = msg.get("content") 

70 if role in ("user", "assistant") and content: 

71 # Skip empty assistant messages (e.g. pure tool calls that were not fully undone?) 

72 # undo_last_step removes tool outputs and tool calls, so we should be clean. 

73 if role == "assistant" and not content.strip(): continue 

74 await self.app.add_chat_message(content, role) 

75 await self.app.log_agent("Undid last action.", "info") 

76 else: 

77 await self.app.add_chat_message("Nothing to undo.", "assistant") 

78 elif cmd == "/resume": 

79 if session.load_last_session(): 

80 self.app.query_one("#chat-scroll").remove_children() 

81 await self.app.add_chat_message(f"Resumed session: {session.current_session_id}", "assistant") 

82 

83 # Re-populate context 

84 context.clear() 

85 for f in session.context_files: 

86 context.add_file(f) 

87 await self.app.update_context_tree() 

88 

89 # Re-populate chat 

90 for msg in session.messages: 

91 role = msg.get("role") 

92 content = msg.get("content") 

93 if role in ("user", "assistant") and content: 

94 if role == "assistant" and not content.strip(): continue 

95 await self.app.add_chat_message(content, role) 

96 

97 await self.app.log_agent(f"Resumed session {session.current_session_id}", "success") 

98 else: 

99 await self.app.add_chat_message("No previous session found.", "assistant") 

100 elif cmd == "/config": 

101 changed = await self.app.push_screen_wait(VoidSettings()) 

102 if changed: 

103 async_client.__init__() 

104 client.__init__() 

105 await self.app.log_agent("System Reconfigured.", "success") 

106 elif cmd == "/persona": 

107 res = await self.app.push_screen_wait(PersonaScreen()) 

108 if res == "saved": 

109 await self.app.log_agent("Persona updated.", "success") 

110 await self.app.add_chat_message("System Prompt updated successfully.", "assistant") 

111 elif cmd == "/lsp": await self.app.push_screen_wait(LSPConfigScreen()) 

112 elif cmd == "/settings": await self.app.push_screen_wait(VoidSettings()) 

113 elif cmd == "/status": 

114 msg = f"**Status:**\n- Model: `{settings.default_model}`\n- Provider: `{settings.provider}`\n- Context: {len(context.loaded_files)} files" 

115 await self.app.add_chat_message(msg, "assistant") 

116 elif cmd == "/sessions": 

117 while True: 

118 res = await self.app.push_screen_wait(VoidSessions()) 

119 if not res: break 

120 

121 action = res.get("action") 

122 if action == "load": 

123 if session.load_session(res.get("id")): 

124 await self.app.add_chat_message(f"Loaded session {res.get('id')}", "assistant") 

125 # Restore context and chat 

126 self.app.query_one("#chat-scroll").remove_children() 

127 context.clear() 

128 for f in session.context_files: context.add_file(f) 

129 await self.app.update_context_tree() 

130 for msg in session.messages: 

131 role = msg.get("role") 

132 content = msg.get("content") 

133 if role in ("user", "assistant") and content: 

134 if role == "assistant" and not content.strip(): continue 

135 await self.app.add_chat_message(content, role) 

136 break 

137 elif action == "delete": 

138 ids = res.get("ids", []) 

139 count = 0 

140 for sid in ids: 

141 if session.delete_session(sid): count += 1 

142 if count > 0: 

143 await self.app.log_agent(f"Deleted {count} sessions.", "success") 

144 else: 

145 break 

146 elif cmd == "/files": self.app.action_toggle_sidebar() 

147 elif cmd == "/add": 

148 if args: 

149 if context.add_file(args[0]): await self.app.update_context_tree() 

150 else: 

151 res = await self.app.push_screen_wait(VoidFilePicker(".")) 

152 if res: 

153 if context.add_file(str(res)): await self.app.update_context_tree() 

154 elif cmd in ("/remove", "/drop", "/rm") and args: 

155 context.remove_file(args[0]) 

156 await self.app.update_context_tree() 

157 await self.app.add_chat_message(f"Removed: {args[0]}", "assistant") 

158 elif cmd == "/tree": 

159 try: 

160 tree_view = tools.tools["get_file_tree"]["implementation"]() 

161 await self.app.add_chat_message(f"**Project Tree**\n```\n{tree_view}\n```", "assistant") 

162 except Exception as e: 

163 await self.app.add_chat_message(f"Tree error: {e}", "assistant") 

164 elif cmd == "/export_json": 

165 if not args: 

166 await self.app.add_chat_message("Usage: /export_json <filename.json>", "assistant") 

167 return 

168 

169 filename = args[0] 

170 try: 

171 # Get the full session data to export 

172 session_data = { 

173 "id": session.current_session_id, 

174 "timestamp": datetime.now().isoformat(), 

175 "model": settings.default_model, 

176 "context_files": list(context.loaded_files.keys()), # Only paths 

177 "messages": session.messages, 

178 "allowed_tools": session.allowed_tools, 

179 "total_tokens": session.total_tokens, 

180 } 

181 

182 Path(filename).write_text(json.dumps(session_data, indent=2), encoding="utf-8") 

183 await self.app.add_chat_message(f"Exported session data to `{filename}`", "assistant") 

184 except Exception as e: 

185 await self.app.add_chat_message(f"Export failed: {e}", "assistant") 

186 elif cmd == "/health": 

187 await self.app.add_chat_message("🩺 Running System Diagnostics...", "assistant") 

188 report = [] 

189 

190 # 1. Config 

191 report.append(f"✅ Provider: {settings.provider} ({settings.default_model})") 

192 

193 # 2. Tools 

194 for tool in ["git", "rg", "python3"]: 

195 path = shutil.which(tool) 

196 if path: 

197 report.append(f"✅ Tool '{tool}': Found") 

198 else: 

199 report.append(f"❌ Tool '{tool}': Missing") 

200 

201 # 3. Connectivity 

202 try: 

203 await async_client.list_models() 

204 report.append("✅ API Connection: OK") 

205 except Exception as e: 

206 report.append(f"❌ API Connection: Failed ({e})") 

207 

208 await self.app.add_chat_message("\n".join(report), "assistant") 

209 elif cmd == "/help": await self.app.push_screen_wait(VoidHelp()) 

210 elif cmd == "/mcp": 

211 if args and args[0].lower() == "bundle": 

212 if len(args) < 2: 

213 await self.app.add_chat_message("Usage: /mcp bundle <bundle_name>", "assistant") 

214 return 

215 bundle_name = args[1] 

216 if bundle_name not in settings.mcp_bundles: 

217 await self.app.add_chat_message(f"Error: MCP bundle '{bundle_name}' not found.", "assistant") 

218 return 

219 

220 bundle_servers_to_activate = settings.mcp_bundles[bundle_name].get("servers", []) 

221 

222 # Store original mcp_servers to restore later if needed, or if this is an override 

223 original_mcp_servers = settings.mcp_servers.copy() 

224 

225 # Construct the new active mcp_servers dictionary based on the bundle 

226 new_active_mcp_servers = {} 

227 for server_name in bundle_servers_to_activate: 

228 if server_name in original_mcp_servers: # Only add if the server is defined globally 

229 new_active_mcp_servers[server_name] = original_mcp_servers[server_name] 

230 else: 

231 await self.app.add_chat_message(f"Warning: Server '{server_name}' in bundle '{bundle_name}' is not defined in global or project MCP servers.", "assistant") 

232 

233 # Temporarily update settings.mcp_servers to reflect the active bundle 

234 settings.mcp_servers = new_active_mcp_servers 

235 

236 # Reload MCP client with the new server set 

237 mcp_client.reload() 

238 

239 activated_server_names = ", ".join(new_active_mcp_servers.keys()) if new_active_mcp_servers else "None" 

240 await self.app.log_agent(f"MCP Bundle '{bundle_name}' activated. Servers: {activated_server_names}", "success") 

241 await self.app.add_chat_message(f"✅ MCP Bundle **'{bundle_name}'** activated. Active servers: `{activated_server_names}`", "assistant") 

242 else: 

243 await self.app.push_screen_wait(MCPSettingsScreen()) 

244 elif cmd == "/models": 

245 sel = await self.app.push_screen_wait(VoidModels()) 

246 if sel: 

247 settings.default_model = sel 

248 settings.save() 

249 await self.app.log_agent(f"Model switched to: {sel}", "info") 

250 await self.app.add_chat_message(f"Active Model: **{sel}**", "system") 

251 elif cmd == "/recipes": 

252 rec = await self.app.push_screen_wait(VoidRecipes()) 

253 if rec: 

254 await self.app.add_chat_message(f"🚀 Running Recipe: **{rec}**...", "assistant") 

255 await self.app.log_agent(f"Recipe: {rec}", "tool") 

256 from .recipes import RecipeRunner 

257 runner = RecipeRunner() 

258 try: 

259 results = await asyncio.to_thread(runner.run, rec) 

260 output = f"### Recipe Complete: {rec}\n" 

261 for step in results: 

262 icon = "✅" if step.success else "❌" 

263 output += f"- {icon} **{step.name}**\n" 

264 if step.output: 

265 preview = step.output.strip() 

266 if len(preview) > 300: preview = preview[:300] + "..." 

267 output += f" ```\n{preview}\n ```\n" 

268 await self.app.add_chat_message(output, "assistant") 

269 except Exception as e: 

270 await self.app.add_chat_message(f"Recipe Error: {e}", "assistant") 

271 elif cmd == "/prompts": 

272 from .prompts import prompt_manager # Import here to avoid circular dependencies potentially 

273 

274 if args and args[0].lower() == "export": 

275 if len(args) < 2: 

276 await self.app.add_chat_message("Usage: /prompts export <filename.json>", "assistant") 

277 return 

278 filename = Path(args[1]) 

279 try: 

280 prompt_manager.export_prompts(filename) 

281 await self.app.add_chat_message(f"✅ Prompts exported to `{filename}`.", "assistant") 

282 await self.app.log_agent(f"Prompts exported to {filename}", "success") 

283 except Exception as e: 

284 await self.app.add_chat_message(f"❌ Error exporting prompts: {e}", "assistant") 

285 await self.app.log_agent(f"Error exporting prompts: {e}", "error") 

286 elif args and args[0].lower() == "import": 

287 if len(args) < 2: 

288 await self.app.add_chat_message("Usage: /prompts import <filename.json>", "assistant") 

289 return 

290 filename = Path(args[1]) 

291 try: 

292 added_count = prompt_manager.import_prompts(filename) 

293 await self.app.add_chat_message(f"✅ Imported {added_count} new prompts from `{filename}`.", "assistant") 

294 await self.app.log_agent(f"Imported {added_count} prompts from {filename}", "success") 

295 except FileNotFoundError: 

296 await self.app.add_chat_message(f"❌ Error: File not found: `{filename}`.", "assistant") 

297 await self.app.log_agent(f"Prompt import file not found: {filename}", "error") 

298 except Exception as e: 

299 await self.app.add_chat_message(f"❌ Error importing prompts: {e}", "assistant") 

300 await self.app.log_agent(f"Error importing prompts: {e}", "error") 

301 else: 

302 await self.app.action_toggle_prompts() 

303 elif cmd == "/tools": await self.app.push_screen_wait(ToolsScreen()) 

304 elif cmd == "/yolo": self.app.toggle_yolo() 

305 elif cmd == "/search": await self.app.action_search_ui() 

306 elif cmd == "/reasoning": 

307 settings.reasoning_mode = not settings.reasoning_mode 

308 settings.save() 

309 status = "ENABLED" if settings.reasoning_mode else "DISABLED" 

310 await self.app.log_agent(f"Reasoning Mode: {status}", "success") 

311 await self.app.add_chat_message(f"🧠 Reasoning Mode: **{status}**", "assistant") 

312 else: await self.app.add_chat_message(f"Unknown command: `{cmd}`. Type `/help` for a list of commands.", "assistant") 

313 except Exception as e: 

314 await self.app.add_chat_message(f"Command Error: {e}", "assistant") 

315 await self.app.log_agent(f"Cmd Error: {e}", "error") 

316