hanzo-mcp 0.6.13__py3-none-any.whl → 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hanzo-mcp might be problematic. Click here for more details.

Files changed (62) hide show
  1. hanzo_mcp/analytics/__init__.py +5 -0
  2. hanzo_mcp/analytics/posthog_analytics.py +364 -0
  3. hanzo_mcp/cli.py +3 -3
  4. hanzo_mcp/cli_enhanced.py +3 -3
  5. hanzo_mcp/config/settings.py +1 -1
  6. hanzo_mcp/config/tool_config.py +18 -4
  7. hanzo_mcp/server.py +34 -1
  8. hanzo_mcp/tools/__init__.py +65 -2
  9. hanzo_mcp/tools/agent/__init__.py +84 -3
  10. hanzo_mcp/tools/agent/agent_tool.py +102 -4
  11. hanzo_mcp/tools/agent/agent_tool_v2.py +492 -0
  12. hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
  13. hanzo_mcp/tools/agent/clarification_tool.py +68 -0
  14. hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
  15. hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
  16. hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
  17. hanzo_mcp/tools/agent/code_auth.py +436 -0
  18. hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
  19. hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
  20. hanzo_mcp/tools/agent/critic_tool.py +376 -0
  21. hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
  22. hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
  23. hanzo_mcp/tools/agent/iching_tool.py +380 -0
  24. hanzo_mcp/tools/agent/network_tool.py +273 -0
  25. hanzo_mcp/tools/agent/prompt.py +62 -20
  26. hanzo_mcp/tools/agent/review_tool.py +433 -0
  27. hanzo_mcp/tools/agent/swarm_tool.py +535 -0
  28. hanzo_mcp/tools/agent/swarm_tool_v2.py +654 -0
  29. hanzo_mcp/tools/common/base.py +1 -0
  30. hanzo_mcp/tools/common/batch_tool.py +102 -10
  31. hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
  32. hanzo_mcp/tools/common/forgiving_edit.py +243 -0
  33. hanzo_mcp/tools/common/paginated_base.py +230 -0
  34. hanzo_mcp/tools/common/paginated_response.py +307 -0
  35. hanzo_mcp/tools/common/pagination.py +226 -0
  36. hanzo_mcp/tools/common/tool_list.py +3 -0
  37. hanzo_mcp/tools/common/truncate.py +101 -0
  38. hanzo_mcp/tools/filesystem/__init__.py +29 -0
  39. hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
  40. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
  41. hanzo_mcp/tools/lsp/__init__.py +5 -0
  42. hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
  43. hanzo_mcp/tools/memory/__init__.py +76 -0
  44. hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
  45. hanzo_mcp/tools/memory/memory_tools.py +456 -0
  46. hanzo_mcp/tools/search/__init__.py +6 -0
  47. hanzo_mcp/tools/search/find_tool.py +581 -0
  48. hanzo_mcp/tools/search/unified_search.py +953 -0
  49. hanzo_mcp/tools/shell/__init__.py +5 -0
  50. hanzo_mcp/tools/shell/auto_background.py +203 -0
  51. hanzo_mcp/tools/shell/base_process.py +53 -27
  52. hanzo_mcp/tools/shell/bash_tool.py +17 -33
  53. hanzo_mcp/tools/shell/npx_tool.py +15 -32
  54. hanzo_mcp/tools/shell/streaming_command.py +594 -0
  55. hanzo_mcp/tools/shell/uvx_tool.py +15 -32
  56. hanzo_mcp/types.py +23 -0
  57. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/METADATA +229 -71
  58. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/RECORD +61 -24
  59. hanzo_mcp-0.6.13.dist-info/licenses/LICENSE +0 -21
  60. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/WHEEL +0 -0
  61. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/entry_points.txt +0 -0
  62. {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,508 @@
1
+ """Claude Desktop authentication management.
2
+
3
+ This module provides tools to automate Claude Desktop login/logout,
4
+ manage separate accounts for swarm agents, and handle authentication flows.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional, Tuple, Dict, Any
15
+ import json
16
+ import webbrowser
17
+ from urllib.parse import urlparse, parse_qs
18
+
19
+ from hanzo_mcp.tools.common.base import BaseTool
20
+ from hanzo_mcp.tools.common.context import create_tool_context
21
+
22
+
23
+ class ClaudeDesktopAuth:
24
+ """Manages Claude Desktop authentication."""
25
+
26
+ # Claude Desktop paths
27
+ CLAUDE_APP_MAC = "/Applications/Claude.app"
28
+ CLAUDE_CONFIG_DIR = Path.home() / ".claude"
29
+ CLAUDE_SESSION_FILE = CLAUDE_CONFIG_DIR / "session.json"
30
+ CLAUDE_ACCOUNTS_FILE = CLAUDE_CONFIG_DIR / "accounts.json"
31
+
32
+ # Authentication endpoints
33
+ CLAUDE_LOGIN_URL = "https://claude.ai/login"
34
+ CLAUDE_API_URL = "https://api.claude.ai"
35
+
36
+ def __init__(self):
37
+ """Initialize Claude Desktop auth manager."""
38
+ self.ensure_config_dir()
39
+
40
+ def ensure_config_dir(self):
41
+ """Ensure Claude config directory exists."""
42
+ self.CLAUDE_CONFIG_DIR.mkdir(exist_ok=True)
43
+
44
+ def is_claude_installed(self) -> bool:
45
+ """Check if Claude Desktop is installed."""
46
+ if os.path.exists(self.CLAUDE_APP_MAC):
47
+ return True
48
+
49
+ # Check if claude command is available
50
+ try:
51
+ result = subprocess.run(
52
+ ["which", "claude"],
53
+ capture_output=True,
54
+ text=True
55
+ )
56
+ return result.returncode == 0
57
+ except:
58
+ return False
59
+
60
+ def is_logged_in(self, account: Optional[str] = None) -> bool:
61
+ """Check if Claude Desktop is logged in.
62
+
63
+ Args:
64
+ account: Optional account identifier to check
65
+
66
+ Returns:
67
+ True if logged in
68
+ """
69
+ if not self.CLAUDE_SESSION_FILE.exists():
70
+ return False
71
+
72
+ try:
73
+ with open(self.CLAUDE_SESSION_FILE, 'r') as f:
74
+ session = json.load(f)
75
+
76
+ # Check if session is valid
77
+ if not session.get("access_token"):
78
+ return False
79
+
80
+ # Check expiry if available
81
+ if "expires_at" in session:
82
+ if time.time() > session["expires_at"]:
83
+ return False
84
+
85
+ # Check specific account if requested
86
+ if account and session.get("account") != account:
87
+ return False
88
+
89
+ return True
90
+ except:
91
+ return False
92
+
93
+ def get_current_account(self) -> Optional[str]:
94
+ """Get the currently logged in account."""
95
+ if not self.is_logged_in():
96
+ return None
97
+
98
+ try:
99
+ with open(self.CLAUDE_SESSION_FILE, 'r') as f:
100
+ session = json.load(f)
101
+ return session.get("account", session.get("email"))
102
+ except:
103
+ return None
104
+
105
+ async def login_interactive(
106
+ self,
107
+ account: Optional[str] = None,
108
+ headless: bool = False
109
+ ) -> Tuple[bool, str]:
110
+ """Login to Claude Desktop interactively.
111
+
112
+ Args:
113
+ account: Optional account email/identifier
114
+ headless: Whether to run in headless mode
115
+
116
+ Returns:
117
+ Tuple of (success, message)
118
+ """
119
+ # Check if already logged in
120
+ if self.is_logged_in(account):
121
+ current = self.get_current_account()
122
+ return True, f"Already logged in as {current}"
123
+
124
+ # Start login flow
125
+ if headless:
126
+ return await self._login_headless(account)
127
+ else:
128
+ return await self._login_browser(account)
129
+
130
+ async def _login_browser(self, account: Optional[str]) -> Tuple[bool, str]:
131
+ """Login using browser flow."""
132
+ # Generate state for OAuth-like flow
133
+ state = os.urandom(16).hex()
134
+
135
+ # Create callback server
136
+ callback_port = 9876
137
+ auth_code = None
138
+
139
+ async def handle_callback(reader, writer):
140
+ """Handle OAuth callback."""
141
+ nonlocal auth_code
142
+
143
+ # Read request
144
+ request = await reader.read(1024)
145
+ request_str = request.decode()
146
+
147
+ # Extract code from query params
148
+ if "GET /" in request_str:
149
+ path = request_str.split(" ")[1]
150
+ if "?code=" in path:
151
+ query = path.split("?")[1]
152
+ params = parse_qs(query)
153
+ if "code" in params:
154
+ auth_code = params["code"][0]
155
+
156
+ # Send response
157
+ response = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
158
+ response += b"<html><body><h1>Authentication successful!</h1>"
159
+ response += b"<p>You can close this window.</p></body></html>"
160
+ writer.write(response)
161
+ await writer.drain()
162
+ writer.close()
163
+
164
+ # Start callback server
165
+ server = await asyncio.start_server(
166
+ handle_callback,
167
+ 'localhost',
168
+ callback_port
169
+ )
170
+
171
+ # Build login URL
172
+ login_url = f"{self.CLAUDE_LOGIN_URL}?callback=http://localhost:{callback_port}&state={state}"
173
+ if account:
174
+ login_url += f"&login_hint={account}"
175
+
176
+ # Open browser
177
+ print(f"Opening browser for Claude login...")
178
+ print(f"URL: {login_url}")
179
+ webbrowser.open(login_url)
180
+
181
+ # Wait for callback (timeout after 2 minutes)
182
+ try:
183
+ start_time = time.time()
184
+ while not auth_code and (time.time() - start_time) < 120:
185
+ await asyncio.sleep(0.5)
186
+
187
+ if auth_code:
188
+ # Exchange code for session
189
+ success = await self._exchange_code_for_session(auth_code, account)
190
+ if success:
191
+ return True, f"Successfully logged in as {account or 'default'}"
192
+ else:
193
+ return False, "Failed to exchange auth code for session"
194
+ else:
195
+ return False, "Login timeout - no auth code received"
196
+
197
+ finally:
198
+ server.close()
199
+ await server.wait_closed()
200
+
201
+ async def _login_headless(self, account: Optional[str]) -> Tuple[bool, str]:
202
+ """Login in headless mode using TTY automation."""
203
+ # This would use expect/pexpect or similar to automate the CLI
204
+ # For now, return a placeholder
205
+ return False, "Headless login not yet implemented"
206
+
207
+ async def _exchange_code_for_session(
208
+ self,
209
+ code: str,
210
+ account: Optional[str]
211
+ ) -> bool:
212
+ """Exchange auth code for session token."""
213
+ # This would make API calls to exchange the code
214
+ # For now, create a mock session
215
+ session = {
216
+ "access_token": f"mock_token_{code[:8]}",
217
+ "account": account or "default",
218
+ "email": account,
219
+ "expires_at": time.time() + 3600 * 24, # 24 hours
220
+ "created_at": time.time()
221
+ }
222
+
223
+ try:
224
+ with open(self.CLAUDE_SESSION_FILE, 'w') as f:
225
+ json.dump(session, f, indent=2)
226
+ return True
227
+ except:
228
+ return False
229
+
230
+ async def logout(self, account: Optional[str] = None) -> Tuple[bool, str]:
231
+ """Logout from Claude Desktop.
232
+
233
+ Args:
234
+ account: Optional account to logout (if multiple accounts)
235
+
236
+ Returns:
237
+ Tuple of (success, message)
238
+ """
239
+ current = self.get_current_account()
240
+
241
+ if not current:
242
+ return True, "No active session to logout"
243
+
244
+ if account and current != account:
245
+ return False, f"Not logged in as {account} (current: {current})"
246
+
247
+ try:
248
+ # Remove session file
249
+ if self.CLAUDE_SESSION_FILE.exists():
250
+ self.CLAUDE_SESSION_FILE.unlink()
251
+
252
+ # Clear any cached credentials
253
+ self._clear_credentials_cache()
254
+
255
+ return True, f"Successfully logged out {current}"
256
+ except Exception as e:
257
+ return False, f"Logout failed: {str(e)}"
258
+
259
+ def _clear_credentials_cache(self):
260
+ """Clear any cached credentials."""
261
+ # Clear keychain on macOS
262
+ if os.path.exists("/usr/bin/security"):
263
+ try:
264
+ subprocess.run([
265
+ "/usr/bin/security",
266
+ "delete-generic-password",
267
+ "-s", "claude.ai",
268
+ "-a", "claude-desktop"
269
+ ], capture_output=True)
270
+ except:
271
+ pass
272
+
273
+ def switch_account(self, account: str) -> Tuple[bool, str]:
274
+ """Switch to a different Claude account.
275
+
276
+ Args:
277
+ account: Account identifier to switch to
278
+
279
+ Returns:
280
+ Tuple of (success, message)
281
+ """
282
+ # Load accounts configuration
283
+ accounts = self._load_accounts()
284
+
285
+ if account not in accounts:
286
+ return False, f"Unknown account: {account}"
287
+
288
+ # Save current session if any
289
+ current = self.get_current_account()
290
+ if current and current != account:
291
+ self._save_session_for_account(current)
292
+
293
+ # Load session for new account
294
+ if self._load_session_for_account(account):
295
+ return True, f"Switched to account: {account}"
296
+ else:
297
+ return False, f"No saved session for account: {account}"
298
+
299
+ def _load_accounts(self) -> Dict[str, Any]:
300
+ """Load accounts configuration."""
301
+ if not self.CLAUDE_ACCOUNTS_FILE.exists():
302
+ return {}
303
+
304
+ try:
305
+ with open(self.CLAUDE_ACCOUNTS_FILE, 'r') as f:
306
+ return json.load(f)
307
+ except:
308
+ return {}
309
+
310
+ def _save_accounts(self, accounts: Dict[str, Any]):
311
+ """Save accounts configuration."""
312
+ with open(self.CLAUDE_ACCOUNTS_FILE, 'w') as f:
313
+ json.dump(accounts, f, indent=2)
314
+
315
+ def _save_session_for_account(self, account: str):
316
+ """Save current session for an account."""
317
+ if not self.CLAUDE_SESSION_FILE.exists():
318
+ return
319
+
320
+ accounts = self._load_accounts()
321
+
322
+ try:
323
+ with open(self.CLAUDE_SESSION_FILE, 'r') as f:
324
+ session = json.load(f)
325
+
326
+ accounts[account] = {
327
+ "session": session,
328
+ "saved_at": time.time()
329
+ }
330
+
331
+ self._save_accounts(accounts)
332
+ except:
333
+ pass
334
+
335
+ def _load_session_for_account(self, account: str) -> bool:
336
+ """Load saved session for an account."""
337
+ accounts = self._load_accounts()
338
+
339
+ if account not in accounts:
340
+ return False
341
+
342
+ account_data = accounts[account]
343
+ if "session" not in account_data:
344
+ return False
345
+
346
+ try:
347
+ # Restore session
348
+ session = account_data["session"]
349
+
350
+ # Update account info
351
+ session["account"] = account
352
+
353
+ with open(self.CLAUDE_SESSION_FILE, 'w') as f:
354
+ json.dump(session, f, indent=2)
355
+
356
+ return True
357
+ except:
358
+ return False
359
+
360
+ def create_agent_account(self, agent_id: str) -> str:
361
+ """Create a unique account identifier for an agent.
362
+
363
+ Args:
364
+ agent_id: Unique agent identifier
365
+
366
+ Returns:
367
+ Account identifier for the agent
368
+ """
369
+ # Generate agent-specific account
370
+ return f"agent_{agent_id}@claude.local"
371
+
372
+ async def ensure_agent_auth(
373
+ self,
374
+ agent_id: str,
375
+ force_new: bool = False
376
+ ) -> Tuple[bool, str]:
377
+ """Ensure an agent is authenticated with its own account.
378
+
379
+ Args:
380
+ agent_id: Unique agent identifier
381
+ force_new: Force new login even if cached
382
+
383
+ Returns:
384
+ Tuple of (success, message/account)
385
+ """
386
+ agent_account = self.create_agent_account(agent_id)
387
+
388
+ # Check if agent already has a session
389
+ if not force_new and self._has_saved_session(agent_account):
390
+ # Try to switch to agent account
391
+ success, msg = self.switch_account(agent_account)
392
+ if success:
393
+ return True, agent_account
394
+
395
+ # Need to create new session for agent
396
+ # For now, we'll use the main account
397
+ # In production, this would create separate auth
398
+ current = self.get_current_account()
399
+ if current:
400
+ # Clone current session for agent
401
+ self._clone_session_for_agent(current, agent_account)
402
+ return True, agent_account
403
+ else:
404
+ return False, "No active session to clone for agent"
405
+
406
+ def _has_saved_session(self, account: str) -> bool:
407
+ """Check if account has a saved session."""
408
+ accounts = self._load_accounts()
409
+ return account in accounts and "session" in accounts[account]
410
+
411
+ def _clone_session_for_agent(self, source: str, agent_account: str):
412
+ """Clone a session for an agent account."""
413
+ # In a real implementation, this would create a sub-session
414
+ # or use delegation tokens
415
+ if self.CLAUDE_SESSION_FILE.exists():
416
+ try:
417
+ with open(self.CLAUDE_SESSION_FILE, 'r') as f:
418
+ session = json.load(f)
419
+
420
+ # Modify for agent
421
+ session["account"] = agent_account
422
+ session["parent_account"] = source
423
+ session["is_agent"] = True
424
+
425
+ # Save as agent session
426
+ accounts = self._load_accounts()
427
+ accounts[agent_account] = {
428
+ "session": session,
429
+ "saved_at": time.time(),
430
+ "parent": source
431
+ }
432
+ self._save_accounts(accounts)
433
+ except:
434
+ pass
435
+
436
+
437
+ class ClaudeDesktopAuthTool(BaseTool):
438
+ """Tool for managing Claude Desktop authentication."""
439
+
440
+ @property
441
+ def name(self) -> str:
442
+ return "claude_auth"
443
+
444
+ @property
445
+ def description(self) -> str:
446
+ return """Manage Claude Desktop authentication.
447
+
448
+ Actions:
449
+ - status: Check login status
450
+ - login: Login to Claude Desktop
451
+ - logout: Logout from Claude Desktop
452
+ - switch: Switch between accounts
453
+ - ensure_agent: Ensure agent has auth
454
+
455
+ Usage:
456
+ claude_auth status
457
+ claude_auth login --account user@example.com
458
+ claude_auth logout
459
+ claude_auth switch agent_1
460
+ claude_auth ensure_agent swarm_agent_1"""
461
+
462
+ def __init__(self):
463
+ """Initialize the auth tool."""
464
+ self.auth = ClaudeDesktopAuth()
465
+
466
+ async def call(self, ctx, action: str = "status", **kwargs) -> str:
467
+ """Execute auth action."""
468
+ tool_ctx = create_tool_context(ctx)
469
+ await tool_ctx.set_tool_info(self.name)
470
+
471
+ if action == "status":
472
+ if self.auth.is_logged_in():
473
+ account = self.auth.get_current_account()
474
+ return f"Logged in as: {account}"
475
+ else:
476
+ return "Not logged in"
477
+
478
+ elif action == "login":
479
+ account = kwargs.get("account")
480
+ headless = kwargs.get("headless", False)
481
+ success, msg = await self.auth.login_interactive(account, headless)
482
+ return msg
483
+
484
+ elif action == "logout":
485
+ account = kwargs.get("account")
486
+ success, msg = await self.auth.logout(account)
487
+ return msg
488
+
489
+ elif action == "switch":
490
+ account = kwargs.get("account")
491
+ if not account:
492
+ return "Error: account required for switch"
493
+ success, msg = self.auth.switch_account(account)
494
+ return msg
495
+
496
+ elif action == "ensure_agent":
497
+ agent_id = kwargs.get("agent_id")
498
+ if not agent_id:
499
+ return "Error: agent_id required"
500
+ force_new = kwargs.get("force_new", False)
501
+ success, result = await self.auth.ensure_agent_auth(agent_id, force_new)
502
+ if success:
503
+ return f"Agent authenticated as: {result}"
504
+ else:
505
+ return f"Failed: {result}"
506
+
507
+ else:
508
+ return f"Unknown action: {action}"
@@ -0,0 +1,191 @@
1
+ """Base class for CLI-based AI agent tools.
2
+
3
+ This provides common functionality for spawning CLI-based AI coding assistants
4
+ like Claude Code, OpenAI Codex, Google Gemini, and Grok.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ from abc import abstractmethod
14
+ from pathlib import Path
15
+ from typing import Optional, Dict, Any, List, Tuple
16
+ from mcp.server.fastmcp import Context as MCPContext
17
+
18
+ from hanzo_mcp.tools.common.base import BaseTool
19
+ from hanzo_mcp.tools.common.context import create_tool_context
20
+ from hanzo_mcp.tools.common.permissions import PermissionManager
21
+
22
+
23
+ class CLIAgentBase(BaseTool):
24
+ """Base class for CLI-based AI agent tools."""
25
+
26
+ def __init__(
27
+ self,
28
+ permission_manager: PermissionManager,
29
+ command_name: str,
30
+ provider_name: str,
31
+ default_model: Optional[str] = None,
32
+ env_vars: Optional[List[str]] = None,
33
+ **kwargs
34
+ ):
35
+ """Initialize CLI agent base.
36
+
37
+ Args:
38
+ permission_manager: Permission manager for access control
39
+ command_name: The CLI command name (e.g., 'claude', 'openai')
40
+ provider_name: The provider name (e.g., 'Claude', 'OpenAI')
41
+ default_model: Default model to use
42
+ env_vars: List of environment variables to check for API keys
43
+ **kwargs: Additional arguments
44
+ """
45
+ self.permission_manager = permission_manager
46
+ self.command_name = command_name
47
+ self.provider_name = provider_name
48
+ self.default_model = default_model
49
+ self.env_vars = env_vars or []
50
+
51
+ def is_installed(self) -> bool:
52
+ """Check if the CLI tool is installed."""
53
+ return shutil.which(self.command_name) is not None
54
+
55
+ def has_api_key(self) -> bool:
56
+ """Check if API key is available in environment."""
57
+ if not self.env_vars:
58
+ return True # No API key needed
59
+
60
+ for var in self.env_vars:
61
+ if os.environ.get(var):
62
+ return True
63
+ return False
64
+
65
+ @abstractmethod
66
+ def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
67
+ """Get CLI arguments for the specific tool.
68
+
69
+ Args:
70
+ prompt: The prompt to send
71
+ **kwargs: Additional arguments
72
+
73
+ Returns:
74
+ List of command arguments
75
+ """
76
+ pass
77
+
78
+ async def execute_cli(
79
+ self,
80
+ ctx: MCPContext,
81
+ prompt: str,
82
+ working_dir: Optional[str] = None,
83
+ timeout: int = 300,
84
+ **kwargs
85
+ ) -> str:
86
+ """Execute the CLI command.
87
+
88
+ Args:
89
+ ctx: MCP context
90
+ prompt: The prompt to send
91
+ working_dir: Working directory for the command
92
+ timeout: Command timeout in seconds
93
+ **kwargs: Additional arguments
94
+
95
+ Returns:
96
+ Command output
97
+ """
98
+ tool_ctx = create_tool_context(ctx)
99
+
100
+ # Check if installed
101
+ if not self.is_installed():
102
+ error_msg = f"{self.provider_name} CLI ({self.command_name}) is not installed. "
103
+ error_msg += f"Please install it first: https://github.com/anthropics/{self.command_name}"
104
+ await tool_ctx.error(error_msg)
105
+ return f"Error: {error_msg}"
106
+
107
+ # Check API key if needed
108
+ if not self.has_api_key():
109
+ error_msg = f"No API key found for {self.provider_name}. "
110
+ error_msg += f"Set one of: {', '.join(self.env_vars)}"
111
+ await tool_ctx.error(error_msg)
112
+ return f"Error: {error_msg}"
113
+
114
+ # Get command arguments
115
+ cli_args = self.get_cli_args(prompt, **kwargs)
116
+
117
+ # Log command
118
+ await tool_ctx.info(f"Executing {self.provider_name}: {self.command_name} {' '.join(cli_args[:3])}...")
119
+
120
+ try:
121
+ # Create temp file for prompt if needed
122
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
123
+ f.write(prompt)
124
+ prompt_file = f.name
125
+
126
+ # Some CLIs might need the prompt via stdin or file
127
+ if '--prompt-file' in cli_args:
128
+ # Replace placeholder with actual file
129
+ cli_args = [arg.replace('--prompt-file', prompt_file) if arg == '--prompt-file' else arg for arg in cli_args]
130
+
131
+ # Execute command
132
+ process = await asyncio.create_subprocess_exec(
133
+ self.command_name,
134
+ *cli_args,
135
+ stdout=asyncio.subprocess.PIPE,
136
+ stderr=asyncio.subprocess.PIPE,
137
+ stdin=asyncio.subprocess.PIPE,
138
+ cwd=working_dir or os.getcwd()
139
+ )
140
+
141
+ # Send prompt via stdin if not using file
142
+ if '--prompt-file' not in cli_args:
143
+ stdout, stderr = await asyncio.wait_for(
144
+ process.communicate(input=prompt.encode()),
145
+ timeout=timeout
146
+ )
147
+ else:
148
+ stdout, stderr = await asyncio.wait_for(
149
+ process.communicate(),
150
+ timeout=timeout
151
+ )
152
+
153
+ # Clean up temp file
154
+ try:
155
+ os.unlink(prompt_file)
156
+ except:
157
+ pass
158
+
159
+ if process.returncode != 0:
160
+ error_msg = stderr.decode() if stderr else "Unknown error"
161
+ await tool_ctx.error(f"{self.provider_name} failed: {error_msg}")
162
+ return f"Error: {error_msg}"
163
+
164
+ result = stdout.decode()
165
+ await tool_ctx.info(f"{self.provider_name} completed successfully")
166
+ return result
167
+
168
+ except asyncio.TimeoutError:
169
+ await tool_ctx.error(f"{self.provider_name} timed out after {timeout} seconds")
170
+ return f"Error: Command timed out after {timeout} seconds"
171
+ except Exception as e:
172
+ await tool_ctx.error(f"{self.provider_name} error: {str(e)}")
173
+ return f"Error: {str(e)}"
174
+
175
+ async def call(
176
+ self,
177
+ ctx: MCPContext,
178
+ prompts: str,
179
+ **kwargs
180
+ ) -> str:
181
+ """Execute the CLI agent.
182
+
183
+ Args:
184
+ ctx: MCP context
185
+ prompts: The prompt(s) to send
186
+ **kwargs: Additional arguments
187
+
188
+ Returns:
189
+ Agent response
190
+ """
191
+ return await self.execute_cli(ctx, prompts, **kwargs)