hanzo-mcp 0.6.13__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.
- 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 +459 -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 +594 -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.0.dist-info}/METADATA +228 -71
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.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.0.dist-info}/WHEEL +0 -0
- {hanzo_mcp-0.6.13.dist-info → hanzo_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.6.13.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}"
|