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.
- hanzo_mcp/analytics/__init__.py +5 -0
- hanzo_mcp/analytics/posthog_analytics.py +364 -0
- hanzo_mcp/cli.py +3 -3
- hanzo_mcp/cli_enhanced.py +3 -3
- hanzo_mcp/config/settings.py +1 -1
- hanzo_mcp/config/tool_config.py +18 -4
- hanzo_mcp/server.py +34 -1
- hanzo_mcp/tools/__init__.py +65 -2
- hanzo_mcp/tools/agent/__init__.py +84 -3
- hanzo_mcp/tools/agent/agent_tool.py +102 -4
- hanzo_mcp/tools/agent/agent_tool_v2.py +492 -0
- hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
- hanzo_mcp/tools/agent/clarification_tool.py +68 -0
- hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
- hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
- hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
- hanzo_mcp/tools/agent/code_auth.py +436 -0
- hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
- hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
- hanzo_mcp/tools/agent/critic_tool.py +376 -0
- hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
- hanzo_mcp/tools/agent/iching_tool.py +380 -0
- hanzo_mcp/tools/agent/network_tool.py +273 -0
- hanzo_mcp/tools/agent/prompt.py +62 -20
- hanzo_mcp/tools/agent/review_tool.py +433 -0
- hanzo_mcp/tools/agent/swarm_tool.py +535 -0
- hanzo_mcp/tools/agent/swarm_tool_v2.py +654 -0
- hanzo_mcp/tools/common/base.py +1 -0
- hanzo_mcp/tools/common/batch_tool.py +102 -10
- hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
- hanzo_mcp/tools/common/forgiving_edit.py +243 -0
- hanzo_mcp/tools/common/paginated_base.py +230 -0
- hanzo_mcp/tools/common/paginated_response.py +307 -0
- hanzo_mcp/tools/common/pagination.py +226 -0
- hanzo_mcp/tools/common/tool_list.py +3 -0
- hanzo_mcp/tools/common/truncate.py +101 -0
- hanzo_mcp/tools/filesystem/__init__.py +29 -0
- hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
- hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
- hanzo_mcp/tools/lsp/__init__.py +5 -0
- hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
- hanzo_mcp/tools/memory/__init__.py +76 -0
- hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
- hanzo_mcp/tools/memory/memory_tools.py +456 -0
- hanzo_mcp/tools/search/__init__.py +6 -0
- hanzo_mcp/tools/search/find_tool.py +581 -0
- hanzo_mcp/tools/search/unified_search.py +953 -0
- hanzo_mcp/tools/shell/__init__.py +5 -0
- hanzo_mcp/tools/shell/auto_background.py +203 -0
- hanzo_mcp/tools/shell/base_process.py +53 -27
- hanzo_mcp/tools/shell/bash_tool.py +17 -33
- hanzo_mcp/tools/shell/npx_tool.py +15 -32
- hanzo_mcp/tools/shell/streaming_command.py +594 -0
- hanzo_mcp/tools/shell/uvx_tool.py +15 -32
- hanzo_mcp/types.py +23 -0
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/METADATA +229 -71
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/RECORD +61 -24
- hanzo_mcp-0.6.13.dist-info/licenses/LICENSE +0 -21
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.1.dist-info}/entry_points.txt +0 -0
- {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)
|