hanzo-mcp 0.6.12__py3-none-any.whl → 0.7.0__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 (117) hide show
  1. hanzo_mcp/__init__.py +2 -2
  2. hanzo_mcp/analytics/__init__.py +5 -0
  3. hanzo_mcp/analytics/posthog_analytics.py +364 -0
  4. hanzo_mcp/cli.py +5 -5
  5. hanzo_mcp/cli_enhanced.py +7 -7
  6. hanzo_mcp/cli_plugin.py +91 -0
  7. hanzo_mcp/config/__init__.py +1 -1
  8. hanzo_mcp/config/settings.py +70 -7
  9. hanzo_mcp/config/tool_config.py +20 -6
  10. hanzo_mcp/dev_server.py +3 -3
  11. hanzo_mcp/prompts/project_system.py +1 -1
  12. hanzo_mcp/server.py +40 -3
  13. hanzo_mcp/server_enhanced.py +69 -0
  14. hanzo_mcp/tools/__init__.py +140 -31
  15. hanzo_mcp/tools/agent/__init__.py +85 -4
  16. hanzo_mcp/tools/agent/agent_tool.py +104 -6
  17. hanzo_mcp/tools/agent/agent_tool_v2.py +459 -0
  18. hanzo_mcp/tools/agent/clarification_protocol.py +220 -0
  19. hanzo_mcp/tools/agent/clarification_tool.py +68 -0
  20. hanzo_mcp/tools/agent/claude_cli_tool.py +125 -0
  21. hanzo_mcp/tools/agent/claude_desktop_auth.py +508 -0
  22. hanzo_mcp/tools/agent/cli_agent_base.py +191 -0
  23. hanzo_mcp/tools/agent/code_auth.py +436 -0
  24. hanzo_mcp/tools/agent/code_auth_tool.py +194 -0
  25. hanzo_mcp/tools/agent/codex_cli_tool.py +123 -0
  26. hanzo_mcp/tools/agent/critic_tool.py +376 -0
  27. hanzo_mcp/tools/agent/gemini_cli_tool.py +128 -0
  28. hanzo_mcp/tools/agent/grok_cli_tool.py +128 -0
  29. hanzo_mcp/tools/agent/iching_tool.py +380 -0
  30. hanzo_mcp/tools/agent/network_tool.py +273 -0
  31. hanzo_mcp/tools/agent/prompt.py +62 -20
  32. hanzo_mcp/tools/agent/review_tool.py +433 -0
  33. hanzo_mcp/tools/agent/swarm_tool.py +535 -0
  34. hanzo_mcp/tools/agent/swarm_tool_v2.py +594 -0
  35. hanzo_mcp/tools/common/__init__.py +15 -1
  36. hanzo_mcp/tools/common/base.py +5 -4
  37. hanzo_mcp/tools/common/batch_tool.py +103 -11
  38. hanzo_mcp/tools/common/config_tool.py +2 -2
  39. hanzo_mcp/tools/common/context.py +2 -2
  40. hanzo_mcp/tools/common/context_fix.py +26 -0
  41. hanzo_mcp/tools/common/critic_tool.py +196 -0
  42. hanzo_mcp/tools/common/decorators.py +208 -0
  43. hanzo_mcp/tools/common/enhanced_base.py +106 -0
  44. hanzo_mcp/tools/common/fastmcp_pagination.py +369 -0
  45. hanzo_mcp/tools/common/forgiving_edit.py +243 -0
  46. hanzo_mcp/tools/common/mode.py +116 -0
  47. hanzo_mcp/tools/common/mode_loader.py +105 -0
  48. hanzo_mcp/tools/common/paginated_base.py +230 -0
  49. hanzo_mcp/tools/common/paginated_response.py +307 -0
  50. hanzo_mcp/tools/common/pagination.py +226 -0
  51. hanzo_mcp/tools/common/permissions.py +1 -1
  52. hanzo_mcp/tools/common/personality.py +936 -0
  53. hanzo_mcp/tools/common/plugin_loader.py +287 -0
  54. hanzo_mcp/tools/common/stats.py +4 -4
  55. hanzo_mcp/tools/common/tool_list.py +4 -1
  56. hanzo_mcp/tools/common/truncate.py +101 -0
  57. hanzo_mcp/tools/common/validation.py +1 -1
  58. hanzo_mcp/tools/config/__init__.py +3 -1
  59. hanzo_mcp/tools/config/config_tool.py +1 -1
  60. hanzo_mcp/tools/config/mode_tool.py +209 -0
  61. hanzo_mcp/tools/database/__init__.py +1 -1
  62. hanzo_mcp/tools/editor/__init__.py +1 -1
  63. hanzo_mcp/tools/filesystem/__init__.py +48 -14
  64. hanzo_mcp/tools/filesystem/ast_multi_edit.py +562 -0
  65. hanzo_mcp/tools/filesystem/batch_search.py +3 -3
  66. hanzo_mcp/tools/filesystem/diff.py +2 -2
  67. hanzo_mcp/tools/filesystem/directory_tree_paginated.py +338 -0
  68. hanzo_mcp/tools/filesystem/rules_tool.py +235 -0
  69. hanzo_mcp/tools/filesystem/{unified_search.py → search_tool.py} +12 -12
  70. hanzo_mcp/tools/filesystem/{symbols_unified.py → symbols_tool.py} +104 -5
  71. hanzo_mcp/tools/filesystem/watch.py +3 -2
  72. hanzo_mcp/tools/jupyter/__init__.py +2 -2
  73. hanzo_mcp/tools/jupyter/jupyter.py +1 -1
  74. hanzo_mcp/tools/llm/__init__.py +3 -3
  75. hanzo_mcp/tools/llm/llm_tool.py +648 -143
  76. hanzo_mcp/tools/lsp/__init__.py +5 -0
  77. hanzo_mcp/tools/lsp/lsp_tool.py +512 -0
  78. hanzo_mcp/tools/mcp/__init__.py +2 -2
  79. hanzo_mcp/tools/mcp/{mcp_unified.py → mcp_tool.py} +3 -3
  80. hanzo_mcp/tools/memory/__init__.py +76 -0
  81. hanzo_mcp/tools/memory/knowledge_tools.py +518 -0
  82. hanzo_mcp/tools/memory/memory_tools.py +456 -0
  83. hanzo_mcp/tools/search/__init__.py +6 -0
  84. hanzo_mcp/tools/search/find_tool.py +581 -0
  85. hanzo_mcp/tools/search/unified_search.py +953 -0
  86. hanzo_mcp/tools/shell/__init__.py +11 -6
  87. hanzo_mcp/tools/shell/auto_background.py +203 -0
  88. hanzo_mcp/tools/shell/base_process.py +57 -29
  89. hanzo_mcp/tools/shell/bash_session_executor.py +1 -1
  90. hanzo_mcp/tools/shell/{bash_unified.py → bash_tool.py} +18 -34
  91. hanzo_mcp/tools/shell/command_executor.py +2 -2
  92. hanzo_mcp/tools/shell/{npx_unified.py → npx_tool.py} +16 -33
  93. hanzo_mcp/tools/shell/open.py +2 -2
  94. hanzo_mcp/tools/shell/{process_unified.py → process_tool.py} +1 -1
  95. hanzo_mcp/tools/shell/run_command_windows.py +1 -1
  96. hanzo_mcp/tools/shell/streaming_command.py +594 -0
  97. hanzo_mcp/tools/shell/uvx.py +47 -2
  98. hanzo_mcp/tools/shell/uvx_background.py +47 -2
  99. hanzo_mcp/tools/shell/{uvx_unified.py → uvx_tool.py} +16 -33
  100. hanzo_mcp/tools/todo/__init__.py +14 -19
  101. hanzo_mcp/tools/todo/todo.py +22 -1
  102. hanzo_mcp/tools/vector/__init__.py +1 -1
  103. hanzo_mcp/tools/vector/infinity_store.py +2 -2
  104. hanzo_mcp/tools/vector/project_manager.py +1 -1
  105. hanzo_mcp/types.py +23 -0
  106. hanzo_mcp-0.7.0.dist-info/METADATA +516 -0
  107. hanzo_mcp-0.7.0.dist-info/RECORD +180 -0
  108. {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/entry_points.txt +1 -0
  109. hanzo_mcp/tools/common/palette.py +0 -344
  110. hanzo_mcp/tools/common/palette_loader.py +0 -108
  111. hanzo_mcp/tools/config/palette_tool.py +0 -179
  112. hanzo_mcp/tools/llm/llm_unified.py +0 -851
  113. hanzo_mcp-0.6.12.dist-info/METADATA +0 -339
  114. hanzo_mcp-0.6.12.dist-info/RECORD +0 -135
  115. hanzo_mcp-0.6.12.dist-info/licenses/LICENSE +0 -21
  116. {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/WHEEL +0 -0
  117. {hanzo_mcp-0.6.12.dist-info → hanzo_mcp-0.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,68 @@
1
+ """Clarification tool for agents to request information from main loop."""
2
+
3
+ from typing import Any, Dict, List, Optional, override
4
+
5
+ from hanzo_mcp.tools.common.base import BaseTool
6
+ from mcp.server.fastmcp import Context as MCPContext
7
+ from mcp.server import FastMCP
8
+
9
+
10
+ class ClarificationTool(BaseTool):
11
+ """Tool for agents to request clarification from the main loop."""
12
+
13
+ name = "request_clarification"
14
+
15
+ @property
16
+ @override
17
+ def description(self) -> str:
18
+ """Get the tool description."""
19
+ return """Request clarification from the main loop (not the human user).
20
+
21
+ Use this when you encounter:
22
+ - Ambiguous instructions that could be interpreted multiple ways
23
+ - Missing context needed to complete the task
24
+ - Multiple valid options where you need guidance
25
+ - Operations that need confirmation before proceeding
26
+ - Need for additional information not provided
27
+
28
+ Parameters:
29
+ - type: Type of clarification (AMBIGUOUS_INSTRUCTION, MISSING_CONTEXT, MULTIPLE_OPTIONS, CONFIRMATION_NEEDED, ADDITIONAL_INFO)
30
+ - question: Clear, specific question to ask
31
+ - context: Relevant context (e.g., file_path, current_operation, etc.)
32
+ - options: Optional list of possible choices (for MULTIPLE_OPTIONS type)
33
+
34
+ You can only use this ONCE per task, so make it count!
35
+
36
+ Example:
37
+ request_clarification(
38
+ type="MISSING_CONTEXT",
39
+ question="What is the correct import path for the common package?",
40
+ context={"file_path": "/path/to/file.go", "undefined_symbol": "common"},
41
+ options=["github.com/luxfi/node/common", "github.com/project/common"]
42
+ )"""
43
+
44
+ async def call(
45
+ self,
46
+ ctx: MCPContext,
47
+ type: str,
48
+ question: str,
49
+ context: Dict[str, Any],
50
+ options: Optional[List[str]] = None
51
+ ) -> str:
52
+ """This is a placeholder - actual implementation happens in AgentTool."""
53
+ # This tool is handled specially in the agent execution
54
+ return f"Clarification request: {question}"
55
+
56
+ def register(self, server: FastMCP) -> None:
57
+ """Register the tool with the MCP server."""
58
+ tool_self = self
59
+
60
+ @server.tool(name=self.name, description=self.description)
61
+ async def request_clarification(
62
+ ctx: MCPContext,
63
+ type: str,
64
+ question: str,
65
+ context: Dict[str, Any],
66
+ options: Optional[List[str]] = None
67
+ ) -> str:
68
+ return await tool_self.call(ctx, type, question, context, options)
@@ -0,0 +1,125 @@
1
+ """Claude Code CLI agent tool.
2
+
3
+ This tool provides integration with the Claude Code CLI (claude command),
4
+ allowing programmatic execution of Claude for code tasks.
5
+ """
6
+
7
+ from typing import List, Optional, override, final
8
+ from mcp.server import FastMCP
9
+ from mcp.server.fastmcp import Context as MCPContext
10
+
11
+ from hanzo_mcp.tools.agent.cli_agent_base import CLIAgentBase
12
+ from hanzo_mcp.tools.common.permissions import PermissionManager
13
+ from hanzo_mcp.tools.agent.code_auth import get_latest_claude_model
14
+
15
+
16
+ @final
17
+ class ClaudeCLITool(CLIAgentBase):
18
+ """Tool for executing Claude Code CLI."""
19
+
20
+ def __init__(
21
+ self,
22
+ permission_manager: PermissionManager,
23
+ model: Optional[str] = None,
24
+ **kwargs
25
+ ):
26
+ """Initialize Claude CLI tool.
27
+
28
+ Args:
29
+ permission_manager: Permission manager for access control
30
+ model: Optional model override (defaults to latest Sonnet)
31
+ **kwargs: Additional arguments
32
+ """
33
+ super().__init__(
34
+ permission_manager=permission_manager,
35
+ command_name="claude",
36
+ provider_name="Claude Code",
37
+ default_model=model or get_latest_claude_model(),
38
+ env_vars=["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
39
+ **kwargs
40
+ )
41
+
42
+ @property
43
+ @override
44
+ def name(self) -> str:
45
+ """Get the tool name."""
46
+ return "claude_cli"
47
+
48
+ @property
49
+ @override
50
+ def description(self) -> str:
51
+ """Get the tool description."""
52
+ return """Execute Claude Code CLI for advanced code tasks.
53
+
54
+ This tool runs the Claude Code CLI (claude command) for code generation,
55
+ editing, analysis, and other programming tasks. It uses the latest
56
+ Claude 3.5 Sonnet model by default.
57
+
58
+ Features:
59
+ - Direct access to Claude's coding capabilities
60
+ - File-aware context and editing
61
+ - Interactive code generation
62
+ - Supports all Claude Code CLI features
63
+
64
+ Usage:
65
+ claude_cli(prompts="Fix the bug in main.py and add tests")
66
+ claude_cli(prompts="Refactor this class to use dependency injection", model="claude-3-opus-20240229")
67
+
68
+ Requirements:
69
+ - Claude Code CLI must be installed
70
+ - ANTHROPIC_API_KEY or CLAUDE_API_KEY environment variable
71
+ """
72
+
73
+ @override
74
+ def get_cli_args(self, prompt: str, **kwargs) -> List[str]:
75
+ """Get CLI arguments for Claude.
76
+
77
+ Args:
78
+ prompt: The prompt to send
79
+ **kwargs: Additional arguments (model, temperature, etc.)
80
+
81
+ Returns:
82
+ List of command arguments
83
+ """
84
+ args = []
85
+
86
+ # Add model if specified
87
+ model = kwargs.get("model", self.default_model)
88
+ if model:
89
+ args.extend(["--model", model])
90
+
91
+ # Add temperature if specified
92
+ if "temperature" in kwargs:
93
+ args.extend(["--temperature", str(kwargs["temperature"])])
94
+
95
+ # Add max tokens if specified
96
+ if "max_tokens" in kwargs:
97
+ args.extend(["--max-tokens", str(kwargs["max_tokens"])])
98
+
99
+ # Add the prompt
100
+ args.append(prompt)
101
+
102
+ return args
103
+
104
+ @override
105
+ def register(self, mcp_server: FastMCP) -> None:
106
+ """Register this tool with the MCP server."""
107
+ tool_self = self
108
+
109
+ @mcp_server.tool(name=self.name, description=self.description)
110
+ async def claude_cli(
111
+ ctx: MCPContext,
112
+ prompts: str,
113
+ model: Optional[str] = None,
114
+ temperature: Optional[float] = None,
115
+ max_tokens: Optional[int] = None,
116
+ working_dir: Optional[str] = None,
117
+ ) -> str:
118
+ return await tool_self.call(
119
+ ctx,
120
+ prompts=prompts,
121
+ model=model,
122
+ temperature=temperature,
123
+ max_tokens=max_tokens,
124
+ working_dir=working_dir,
125
+ )
@@ -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}"