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,436 @@
|
|
|
1
|
+
"""Claude Code and OpenAI Codex authentication management.
|
|
2
|
+
|
|
3
|
+
This module provides tools to manage API keys and authentication for
|
|
4
|
+
Claude Code CLI and OpenAI Codex, allowing separate accounts for swarm agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import json
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Tuple, Dict, Any, List
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
import keyring
|
|
16
|
+
import getpass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class APICredential:
|
|
21
|
+
"""API credential information."""
|
|
22
|
+
provider: str
|
|
23
|
+
api_key: str
|
|
24
|
+
model: Optional[str] = None
|
|
25
|
+
base_url: Optional[str] = None
|
|
26
|
+
org_id: Optional[str] = None
|
|
27
|
+
description: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CodeAuthManager:
|
|
31
|
+
"""Manages authentication for Claude Code and other AI coding tools."""
|
|
32
|
+
|
|
33
|
+
# Configuration paths
|
|
34
|
+
CONFIG_DIR = Path.home() / ".hanzo" / "auth"
|
|
35
|
+
ACCOUNTS_FILE = CONFIG_DIR / "accounts.json"
|
|
36
|
+
ACTIVE_ACCOUNT_FILE = CONFIG_DIR / "active_account"
|
|
37
|
+
|
|
38
|
+
# Environment variable mappings
|
|
39
|
+
ENV_VARS = {
|
|
40
|
+
"claude": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
|
|
41
|
+
"openai": ["OPENAI_API_KEY"],
|
|
42
|
+
"azure": ["AZURE_OPENAI_API_KEY", "AZURE_API_KEY"],
|
|
43
|
+
"deepseek": ["DEEPSEEK_API_KEY"],
|
|
44
|
+
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
|
45
|
+
"groq": ["GROQ_API_KEY"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Default models
|
|
49
|
+
DEFAULT_MODELS = {
|
|
50
|
+
"claude": "claude-3-5-sonnet-20241022", # Latest Sonnet
|
|
51
|
+
"openai": "gpt-4o",
|
|
52
|
+
"azure": "gpt-4",
|
|
53
|
+
"deepseek": "deepseek-coder",
|
|
54
|
+
"google": "gemini-1.5-pro",
|
|
55
|
+
"groq": "llama3-70b-8192",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def __init__(self):
|
|
59
|
+
"""Initialize auth manager."""
|
|
60
|
+
self.ensure_config_dir()
|
|
61
|
+
self._env_backup = {}
|
|
62
|
+
|
|
63
|
+
def ensure_config_dir(self):
|
|
64
|
+
"""Ensure config directory exists."""
|
|
65
|
+
self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
def get_active_account(self) -> Optional[str]:
|
|
68
|
+
"""Get the currently active account."""
|
|
69
|
+
if self.ACTIVE_ACCOUNT_FILE.exists():
|
|
70
|
+
return self.ACTIVE_ACCOUNT_FILE.read_text().strip()
|
|
71
|
+
return "default"
|
|
72
|
+
|
|
73
|
+
def set_active_account(self, account: str):
|
|
74
|
+
"""Set the active account."""
|
|
75
|
+
self.ACTIVE_ACCOUNT_FILE.write_text(account)
|
|
76
|
+
|
|
77
|
+
def _load_accounts(self) -> Dict[str, Dict[str, Any]]:
|
|
78
|
+
"""Load all accounts."""
|
|
79
|
+
if not self.ACCOUNTS_FILE.exists():
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
with open(self.ACCOUNTS_FILE, 'r') as f:
|
|
84
|
+
return json.load(f)
|
|
85
|
+
except:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
def _save_accounts(self, accounts: Dict[str, Dict[str, Any]]):
|
|
89
|
+
"""Save accounts."""
|
|
90
|
+
with open(self.ACCOUNTS_FILE, 'w') as f:
|
|
91
|
+
json.dump(accounts, f, indent=2)
|
|
92
|
+
|
|
93
|
+
def list_accounts(self) -> List[str]:
|
|
94
|
+
"""List all available accounts."""
|
|
95
|
+
accounts = self._load_accounts()
|
|
96
|
+
return list(accounts.keys())
|
|
97
|
+
|
|
98
|
+
def get_account_info(self, account: str) -> Optional[Dict[str, Any]]:
|
|
99
|
+
"""Get information about an account."""
|
|
100
|
+
accounts = self._load_accounts()
|
|
101
|
+
return accounts.get(account)
|
|
102
|
+
|
|
103
|
+
def create_account(
|
|
104
|
+
self,
|
|
105
|
+
account: str,
|
|
106
|
+
provider: str = "claude",
|
|
107
|
+
api_key: Optional[str] = None,
|
|
108
|
+
model: Optional[str] = None,
|
|
109
|
+
description: Optional[str] = None
|
|
110
|
+
) -> Tuple[bool, str]:
|
|
111
|
+
"""Create a new account.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
account: Account name
|
|
115
|
+
provider: Provider (claude, openai, etc.)
|
|
116
|
+
api_key: API key (will prompt if not provided)
|
|
117
|
+
model: Model to use (defaults to provider default)
|
|
118
|
+
description: Account description
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple of (success, message)
|
|
122
|
+
"""
|
|
123
|
+
accounts = self._load_accounts()
|
|
124
|
+
|
|
125
|
+
if account in accounts:
|
|
126
|
+
return False, f"Account '{account}' already exists"
|
|
127
|
+
|
|
128
|
+
# Get API key if not provided
|
|
129
|
+
if not api_key:
|
|
130
|
+
api_key = self._prompt_for_api_key(provider)
|
|
131
|
+
if not api_key:
|
|
132
|
+
return False, "No API key provided"
|
|
133
|
+
|
|
134
|
+
# Use default model if not specified
|
|
135
|
+
if not model:
|
|
136
|
+
model = self.DEFAULT_MODELS.get(provider)
|
|
137
|
+
|
|
138
|
+
# Store in keyring for security
|
|
139
|
+
try:
|
|
140
|
+
keyring.set_password(f"hanzo-{provider}", account, api_key)
|
|
141
|
+
except:
|
|
142
|
+
# Fallback to file storage (less secure)
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Save account info
|
|
146
|
+
accounts[account] = {
|
|
147
|
+
"provider": provider,
|
|
148
|
+
"model": model,
|
|
149
|
+
"description": description or f"{provider} account",
|
|
150
|
+
"created_at": os.path.getmtime(__file__),
|
|
151
|
+
"has_keyring": self._has_keyring_support()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
self._save_accounts(accounts)
|
|
155
|
+
return True, f"Created account '{account}' for {provider}"
|
|
156
|
+
|
|
157
|
+
def _prompt_for_api_key(self, provider: str) -> Optional[str]:
|
|
158
|
+
"""Prompt user for API key."""
|
|
159
|
+
prompt = f"Enter {provider.upper()} API key: "
|
|
160
|
+
try:
|
|
161
|
+
return getpass.getpass(prompt)
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def _has_keyring_support(self) -> bool:
|
|
166
|
+
"""Check if keyring is available."""
|
|
167
|
+
try:
|
|
168
|
+
keyring.get_keyring()
|
|
169
|
+
return True
|
|
170
|
+
except:
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
def login(self, account: str = "default") -> Tuple[bool, str]:
|
|
174
|
+
"""Login to an account by setting environment variables.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
account: Account name to login to
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Tuple of (success, message)
|
|
181
|
+
"""
|
|
182
|
+
accounts = self._load_accounts()
|
|
183
|
+
|
|
184
|
+
if account not in accounts:
|
|
185
|
+
return False, f"Account '{account}' not found"
|
|
186
|
+
|
|
187
|
+
account_info = accounts[account]
|
|
188
|
+
provider = account_info["provider"]
|
|
189
|
+
|
|
190
|
+
# Get API key from keyring or prompt
|
|
191
|
+
api_key = None
|
|
192
|
+
if account_info.get("has_keyring"):
|
|
193
|
+
try:
|
|
194
|
+
api_key = keyring.get_password(f"hanzo-{provider}", account)
|
|
195
|
+
except:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
if not api_key:
|
|
199
|
+
# Try environment variable
|
|
200
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
201
|
+
if env_var in os.environ:
|
|
202
|
+
api_key = os.environ[env_var]
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
if not api_key:
|
|
206
|
+
api_key = self._prompt_for_api_key(provider)
|
|
207
|
+
if not api_key:
|
|
208
|
+
return False, "No API key available"
|
|
209
|
+
|
|
210
|
+
# Backup current environment
|
|
211
|
+
self._backup_environment(provider)
|
|
212
|
+
|
|
213
|
+
# Set environment variables
|
|
214
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
215
|
+
os.environ[env_var] = api_key
|
|
216
|
+
|
|
217
|
+
# Set active account
|
|
218
|
+
self.set_active_account(account)
|
|
219
|
+
|
|
220
|
+
# Update shell if using claude command
|
|
221
|
+
self._update_claude_command(account_info)
|
|
222
|
+
|
|
223
|
+
return True, f"Logged in as '{account}' ({provider})"
|
|
224
|
+
|
|
225
|
+
def logout(self) -> Tuple[bool, str]:
|
|
226
|
+
"""Logout by clearing environment variables."""
|
|
227
|
+
current = self.get_active_account()
|
|
228
|
+
|
|
229
|
+
if not current or current == "default":
|
|
230
|
+
return False, "No active session"
|
|
231
|
+
|
|
232
|
+
accounts = self._load_accounts()
|
|
233
|
+
if current not in accounts:
|
|
234
|
+
return False, f"Unknown account: {current}"
|
|
235
|
+
|
|
236
|
+
provider = accounts[current]["provider"]
|
|
237
|
+
|
|
238
|
+
# Clear environment variables
|
|
239
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
240
|
+
if env_var in os.environ:
|
|
241
|
+
del os.environ[env_var]
|
|
242
|
+
|
|
243
|
+
# Restore backed up environment if any
|
|
244
|
+
self._restore_environment(provider)
|
|
245
|
+
|
|
246
|
+
# Clear active account
|
|
247
|
+
if self.ACTIVE_ACCOUNT_FILE.exists():
|
|
248
|
+
self.ACTIVE_ACCOUNT_FILE.unlink()
|
|
249
|
+
|
|
250
|
+
return True, f"Logged out from '{current}'"
|
|
251
|
+
|
|
252
|
+
def _backup_environment(self, provider: str):
|
|
253
|
+
"""Backup current environment variables."""
|
|
254
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
255
|
+
if env_var in os.environ:
|
|
256
|
+
self._env_backup[env_var] = os.environ[env_var]
|
|
257
|
+
|
|
258
|
+
def _restore_environment(self, provider: str):
|
|
259
|
+
"""Restore backed up environment variables."""
|
|
260
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
261
|
+
if env_var in self._env_backup:
|
|
262
|
+
os.environ[env_var] = self._env_backup[env_var]
|
|
263
|
+
del self._env_backup[env_var]
|
|
264
|
+
|
|
265
|
+
def _update_claude_command(self, account_info: Dict[str, Any]):
|
|
266
|
+
"""Update claude command configuration if needed."""
|
|
267
|
+
# Check if claude command exists
|
|
268
|
+
try:
|
|
269
|
+
result = subprocess.run(
|
|
270
|
+
["which", "claude"],
|
|
271
|
+
capture_output=True,
|
|
272
|
+
text=True
|
|
273
|
+
)
|
|
274
|
+
if result.returncode == 0:
|
|
275
|
+
# Claude command exists, update its config
|
|
276
|
+
claude_config = Path.home() / ".claude" / "config.json"
|
|
277
|
+
if claude_config.exists():
|
|
278
|
+
try:
|
|
279
|
+
with open(claude_config, 'r') as f:
|
|
280
|
+
config = json.load(f)
|
|
281
|
+
|
|
282
|
+
# Update model if specified
|
|
283
|
+
if account_info.get("model"):
|
|
284
|
+
config["default_model"] = account_info["model"]
|
|
285
|
+
|
|
286
|
+
with open(claude_config, 'w') as f:
|
|
287
|
+
json.dump(config, f, indent=2)
|
|
288
|
+
except:
|
|
289
|
+
pass
|
|
290
|
+
except:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
def switch_account(self, account: str) -> Tuple[bool, str]:
|
|
294
|
+
"""Switch to a different account."""
|
|
295
|
+
# Logout current
|
|
296
|
+
self.logout()
|
|
297
|
+
|
|
298
|
+
# Login to new account
|
|
299
|
+
return self.login(account)
|
|
300
|
+
|
|
301
|
+
def create_agent_account(
|
|
302
|
+
self,
|
|
303
|
+
agent_id: str,
|
|
304
|
+
provider: str = "claude",
|
|
305
|
+
parent_account: Optional[str] = None
|
|
306
|
+
) -> Tuple[bool, str]:
|
|
307
|
+
"""Create an account for a swarm agent.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
agent_id: Unique agent identifier
|
|
311
|
+
provider: AI provider
|
|
312
|
+
parent_account: Parent account to clone from
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Tuple of (success, account_name)
|
|
316
|
+
"""
|
|
317
|
+
agent_account = f"agent_{agent_id}"
|
|
318
|
+
|
|
319
|
+
# If parent account specified, clone its credentials
|
|
320
|
+
if parent_account:
|
|
321
|
+
parent_info = self.get_account_info(parent_account)
|
|
322
|
+
if not parent_info:
|
|
323
|
+
return False, f"Parent account '{parent_account}' not found"
|
|
324
|
+
|
|
325
|
+
# Get parent API key
|
|
326
|
+
api_key = None
|
|
327
|
+
if parent_info.get("has_keyring"):
|
|
328
|
+
try:
|
|
329
|
+
api_key = keyring.get_password(
|
|
330
|
+
f"hanzo-{parent_info['provider']}",
|
|
331
|
+
parent_account
|
|
332
|
+
)
|
|
333
|
+
except:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
if api_key:
|
|
337
|
+
success, msg = self.create_account(
|
|
338
|
+
agent_account,
|
|
339
|
+
provider=parent_info["provider"],
|
|
340
|
+
api_key=api_key,
|
|
341
|
+
model=parent_info.get("model"),
|
|
342
|
+
description=f"Agent account (parent: {parent_account})"
|
|
343
|
+
)
|
|
344
|
+
if success:
|
|
345
|
+
return True, agent_account
|
|
346
|
+
|
|
347
|
+
# Create with current environment
|
|
348
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
349
|
+
if env_var in os.environ:
|
|
350
|
+
success, msg = self.create_account(
|
|
351
|
+
agent_account,
|
|
352
|
+
provider=provider,
|
|
353
|
+
api_key=os.environ[env_var],
|
|
354
|
+
model=self.DEFAULT_MODELS.get(provider),
|
|
355
|
+
description=f"Agent account for {agent_id}"
|
|
356
|
+
)
|
|
357
|
+
if success:
|
|
358
|
+
return True, agent_account
|
|
359
|
+
|
|
360
|
+
return False, "No credentials available for agent"
|
|
361
|
+
|
|
362
|
+
def get_agent_credentials(self, agent_id: str) -> Optional[APICredential]:
|
|
363
|
+
"""Get credentials for an agent.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
agent_id: Agent identifier
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
APICredential if found
|
|
370
|
+
"""
|
|
371
|
+
agent_account = f"agent_{agent_id}"
|
|
372
|
+
account_info = self.get_account_info(agent_account)
|
|
373
|
+
|
|
374
|
+
if not account_info:
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
# Get API key
|
|
378
|
+
api_key = None
|
|
379
|
+
provider = account_info["provider"]
|
|
380
|
+
|
|
381
|
+
if account_info.get("has_keyring"):
|
|
382
|
+
try:
|
|
383
|
+
api_key = keyring.get_password(f"hanzo-{provider}", agent_account)
|
|
384
|
+
except:
|
|
385
|
+
pass
|
|
386
|
+
|
|
387
|
+
if not api_key:
|
|
388
|
+
# Try current environment
|
|
389
|
+
for env_var in self.ENV_VARS.get(provider, []):
|
|
390
|
+
if env_var in os.environ:
|
|
391
|
+
api_key = os.environ[env_var]
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
if not api_key:
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
return APICredential(
|
|
398
|
+
provider=provider,
|
|
399
|
+
api_key=api_key,
|
|
400
|
+
model=account_info.get("model"),
|
|
401
|
+
description=account_info.get("description")
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# Update swarm tool to use latest Sonnet
|
|
406
|
+
def get_latest_claude_model() -> str:
|
|
407
|
+
"""Get the latest Claude model identifier."""
|
|
408
|
+
# As of the knowledge cutoff, this is the latest Sonnet
|
|
409
|
+
# In production, this could query an API for the latest model
|
|
410
|
+
return "claude-3-5-sonnet-20241022"
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Token counting using tiktoken (same as current implementation)
|
|
414
|
+
def count_tokens_streaming(text_stream) -> int:
|
|
415
|
+
"""Count tokens in a streaming fashion.
|
|
416
|
+
|
|
417
|
+
This uses the same tiktoken approach as the truncate module,
|
|
418
|
+
but processes text as it streams.
|
|
419
|
+
"""
|
|
420
|
+
import tiktoken
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
# Use cl100k_base encoding (Claude/GPT-4 compatible)
|
|
424
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
425
|
+
except:
|
|
426
|
+
# Fallback to simple estimation
|
|
427
|
+
return len(text_stream) // 4
|
|
428
|
+
|
|
429
|
+
total_tokens = 0
|
|
430
|
+
for chunk in text_stream:
|
|
431
|
+
if isinstance(chunk, str):
|
|
432
|
+
total_tokens += len(encoding.encode(chunk))
|
|
433
|
+
elif isinstance(chunk, bytes):
|
|
434
|
+
total_tokens += len(encoding.encode(chunk.decode('utf-8', errors='ignore')))
|
|
435
|
+
|
|
436
|
+
return total_tokens
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Claude Code authentication tool.
|
|
2
|
+
|
|
3
|
+
This tool manages API keys and accounts for Claude Code and other AI coding tools.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Annotated, Optional, TypedDict, Unpack, final, override
|
|
7
|
+
from mcp.server import FastMCP
|
|
8
|
+
from mcp.server.fastmcp import Context as MCPContext
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
12
|
+
from hanzo_mcp.tools.common.context import create_tool_context
|
|
13
|
+
from hanzo_mcp.tools.agent.code_auth import CodeAuthManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CodeAuthParams(TypedDict, total=False):
|
|
17
|
+
"""Parameters for code auth tool."""
|
|
18
|
+
action: str
|
|
19
|
+
account: Optional[str]
|
|
20
|
+
provider: Optional[str]
|
|
21
|
+
api_key: Optional[str]
|
|
22
|
+
model: Optional[str]
|
|
23
|
+
description: Optional[str]
|
|
24
|
+
agent_id: Optional[str]
|
|
25
|
+
parent_account: Optional[str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@final
|
|
29
|
+
class CodeAuthTool(BaseTool):
|
|
30
|
+
"""Tool for managing Claude Code authentication and API keys."""
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
@override
|
|
34
|
+
def name(self) -> str:
|
|
35
|
+
"""Get the tool name."""
|
|
36
|
+
return "code_auth"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
@override
|
|
40
|
+
def description(self) -> str:
|
|
41
|
+
"""Get the tool description."""
|
|
42
|
+
return """Manage Claude Code and AI provider authentication.
|
|
43
|
+
|
|
44
|
+
Actions:
|
|
45
|
+
- status: Show current login status
|
|
46
|
+
- list: List all accounts
|
|
47
|
+
- create: Create a new account
|
|
48
|
+
- login: Login to an account
|
|
49
|
+
- logout: Logout current account
|
|
50
|
+
- switch: Switch between accounts
|
|
51
|
+
- agent: Create/get agent account
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
code_auth status
|
|
55
|
+
code_auth list
|
|
56
|
+
code_auth create --account work --provider claude
|
|
57
|
+
code_auth login --account work
|
|
58
|
+
code_auth logout
|
|
59
|
+
code_auth switch --account personal
|
|
60
|
+
code_auth agent --agent_id swarm_1 --parent_account work
|
|
61
|
+
|
|
62
|
+
Providers: claude, openai, azure, deepseek, google, groq"""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
"""Initialize the code auth tool."""
|
|
66
|
+
self.auth_manager = CodeAuthManager()
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
async def call(
|
|
70
|
+
self,
|
|
71
|
+
ctx: MCPContext,
|
|
72
|
+
**params: Unpack[CodeAuthParams],
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Execute the code auth tool.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
ctx: MCP context
|
|
78
|
+
**params: Tool parameters
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Result message
|
|
82
|
+
"""
|
|
83
|
+
tool_ctx = create_tool_context(ctx)
|
|
84
|
+
await tool_ctx.set_tool_info(self.name)
|
|
85
|
+
|
|
86
|
+
action = params.get("action", "status")
|
|
87
|
+
|
|
88
|
+
if action == "status":
|
|
89
|
+
current = self.auth_manager.get_active_account()
|
|
90
|
+
if current:
|
|
91
|
+
info = self.auth_manager.get_account_info(current)
|
|
92
|
+
if info:
|
|
93
|
+
return f"Logged in as: {current} ({info['provider']})"
|
|
94
|
+
return "Not logged in"
|
|
95
|
+
|
|
96
|
+
elif action == "list":
|
|
97
|
+
accounts = self.auth_manager.list_accounts()
|
|
98
|
+
if not accounts:
|
|
99
|
+
return "No accounts configured"
|
|
100
|
+
|
|
101
|
+
current = self.auth_manager.get_active_account()
|
|
102
|
+
lines = ["Configured accounts:"]
|
|
103
|
+
for account in accounts:
|
|
104
|
+
info = self.auth_manager.get_account_info(account)
|
|
105
|
+
marker = " (active)" if account == current else ""
|
|
106
|
+
lines.append(f" - {account}: {info['provider']}{marker}")
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
elif action == "create":
|
|
110
|
+
account = params.get("account")
|
|
111
|
+
if not account:
|
|
112
|
+
return "Error: account name required"
|
|
113
|
+
|
|
114
|
+
provider = params.get("provider", "claude")
|
|
115
|
+
api_key = params.get("api_key")
|
|
116
|
+
model = params.get("model")
|
|
117
|
+
description = params.get("description")
|
|
118
|
+
|
|
119
|
+
success, msg = self.auth_manager.create_account(
|
|
120
|
+
account, provider, api_key, model, description
|
|
121
|
+
)
|
|
122
|
+
return msg
|
|
123
|
+
|
|
124
|
+
elif action == "login":
|
|
125
|
+
account = params.get("account", "default")
|
|
126
|
+
success, msg = self.auth_manager.login(account)
|
|
127
|
+
return msg
|
|
128
|
+
|
|
129
|
+
elif action == "logout":
|
|
130
|
+
success, msg = self.auth_manager.logout()
|
|
131
|
+
return msg
|
|
132
|
+
|
|
133
|
+
elif action == "switch":
|
|
134
|
+
account = params.get("account")
|
|
135
|
+
if not account:
|
|
136
|
+
return "Error: account name required"
|
|
137
|
+
|
|
138
|
+
success, msg = self.auth_manager.switch_account(account)
|
|
139
|
+
return msg
|
|
140
|
+
|
|
141
|
+
elif action == "agent":
|
|
142
|
+
agent_id = params.get("agent_id")
|
|
143
|
+
if not agent_id:
|
|
144
|
+
return "Error: agent_id required"
|
|
145
|
+
|
|
146
|
+
provider = params.get("provider", "claude")
|
|
147
|
+
parent_account = params.get("parent_account")
|
|
148
|
+
|
|
149
|
+
# Try to create agent account
|
|
150
|
+
success, result = self.auth_manager.create_agent_account(
|
|
151
|
+
agent_id, provider, parent_account
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if success:
|
|
155
|
+
# Get credentials
|
|
156
|
+
creds = self.auth_manager.get_agent_credentials(agent_id)
|
|
157
|
+
if creds:
|
|
158
|
+
return f"Agent account ready: {result} ({creds.provider})"
|
|
159
|
+
else:
|
|
160
|
+
return f"Agent account created but no credentials: {result}"
|
|
161
|
+
else:
|
|
162
|
+
return f"Failed to create agent account: {result}"
|
|
163
|
+
|
|
164
|
+
else:
|
|
165
|
+
return f"Unknown action: {action}. Use: status, list, create, login, logout, switch, agent"
|
|
166
|
+
|
|
167
|
+
@override
|
|
168
|
+
def register(self, mcp_server: FastMCP) -> None:
|
|
169
|
+
"""Register this tool with the MCP server."""
|
|
170
|
+
tool_self = self
|
|
171
|
+
|
|
172
|
+
@mcp_server.tool(name=self.name, description=self.description)
|
|
173
|
+
async def code_auth(
|
|
174
|
+
ctx: MCPContext,
|
|
175
|
+
action: str = "status",
|
|
176
|
+
account: Optional[str] = None,
|
|
177
|
+
provider: Optional[str] = None,
|
|
178
|
+
api_key: Optional[str] = None,
|
|
179
|
+
model: Optional[str] = None,
|
|
180
|
+
description: Optional[str] = None,
|
|
181
|
+
agent_id: Optional[str] = None,
|
|
182
|
+
parent_account: Optional[str] = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
return await tool_self.call(
|
|
185
|
+
ctx,
|
|
186
|
+
action=action,
|
|
187
|
+
account=account,
|
|
188
|
+
provider=provider,
|
|
189
|
+
api_key=api_key,
|
|
190
|
+
model=model,
|
|
191
|
+
description=description,
|
|
192
|
+
agent_id=agent_id,
|
|
193
|
+
parent_account=parent_account,
|
|
194
|
+
)
|