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