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,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)
|
|
@@ -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
|