hanzo 0.3.25__py3-none-any.whl → 0.3.27__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 might be problematic. Click here for more details.

hanzo/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Hanzo - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime."""
2
2
 
3
- __version__ = "0.3.25"
3
+ __version__ = "0.3.27"
4
4
  __all__ = ["main", "cli", "__version__"]
5
5
 
6
6
  from .cli import cli, main
@@ -20,6 +20,18 @@ from prompt_toolkit.completion import WordCompleter
20
20
  from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
21
21
  from prompt_toolkit.formatted_text import HTML
22
22
 
23
+ try:
24
+ from ..tools.detector import ToolDetector, AITool
25
+ except ImportError:
26
+ ToolDetector = None
27
+ AITool = None
28
+
29
+ try:
30
+ from .model_selector import QuickModelSelector, BackgroundTaskManager
31
+ except ImportError:
32
+ QuickModelSelector = None
33
+ BackgroundTaskManager = None
34
+
23
35
 
24
36
  class EnhancedHanzoREPL:
25
37
  """Enhanced REPL with model selection and authentication."""
@@ -57,6 +69,17 @@ class EnhancedHanzoREPL:
57
69
  "local:mistral": "Local Mistral",
58
70
  "local:phi-2": "Local Phi-2",
59
71
  }
72
+
73
+ def get_all_models(self):
74
+ """Get all available models including detected tools."""
75
+ models = dict(self.MODELS)
76
+
77
+ # Add detected tools as models
78
+ if self.detected_tools:
79
+ for tool in self.detected_tools:
80
+ models[f"tool:{tool.name}"] = f"{tool.display_name} (Tool)"
81
+
82
+ return models
60
83
 
61
84
  def __init__(self, console: Optional[Console] = None):
62
85
  self.console = console or Console()
@@ -68,8 +91,30 @@ class EnhancedHanzoREPL:
68
91
  self.config = self.load_config()
69
92
  self.auth = self.load_auth()
70
93
 
71
- # Current model
72
- self.current_model = self.config.get("default_model", "gpt-3.5-turbo")
94
+ # Initialize tool detector
95
+ self.tool_detector = ToolDetector(console) if ToolDetector else None
96
+ self.detected_tools = []
97
+ self.current_tool = None
98
+
99
+ # Initialize background task manager
100
+ self.task_manager = BackgroundTaskManager(console) if BackgroundTaskManager else None
101
+
102
+ # Detect available tools and set default
103
+ if self.tool_detector:
104
+ self.detected_tools = self.tool_detector.detect_all()
105
+ default_tool = self.tool_detector.get_default_tool()
106
+
107
+ # If Claude Code is available, use it as default
108
+ if default_tool:
109
+ self.current_model = f"tool:{default_tool.name}"
110
+ self.current_tool = default_tool
111
+ self.console.print(f"[green]✓ Detected {default_tool.display_name} as default AI assistant[/green]")
112
+ else:
113
+ # Fallback to regular models
114
+ self.current_model = self.config.get("default_model", "gpt-3.5-turbo")
115
+ else:
116
+ # No tool detector, use regular models
117
+ self.current_model = self.config.get("default_model", "gpt-3.5-turbo")
73
118
 
74
119
  # Setup session
75
120
  self.session = PromptSession(
@@ -86,9 +131,14 @@ class EnhancedHanzoREPL:
86
131
  "status": self.show_status,
87
132
  "model": self.change_model,
88
133
  "models": self.list_models,
134
+ "tools": self.list_tools,
135
+ "agents": self.list_tools, # Alias for tools
89
136
  "login": self.login,
90
137
  "logout": self.logout,
91
138
  "config": self.show_config,
139
+ "tasks": self.show_tasks,
140
+ "kill": self.kill_task,
141
+ "quick": self.quick_model_select,
92
142
  }
93
143
 
94
144
  self.running = False
@@ -144,8 +194,17 @@ class EnhancedHanzoREPL:
144
194
 
145
195
  def get_model_info(self):
146
196
  """Get current model info string."""
147
- # Determine provider from model name
148
197
  model = self.current_model
198
+
199
+ # Check if using a tool
200
+ if model.startswith("tool:"):
201
+ if self.current_tool:
202
+ return f"[dim cyan]agent: {self.current_tool.display_name}[/dim cyan]"
203
+ else:
204
+ tool_name = model.replace("tool:", "")
205
+ return f"[dim cyan]agent: {tool_name}[/dim cyan]"
206
+
207
+ # Determine provider from model name
149
208
  if model.startswith("gpt"):
150
209
  provider = "openai"
151
210
  elif model.startswith("claude"):
@@ -161,10 +220,7 @@ class EnhancedHanzoREPL:
161
220
  else:
162
221
  provider = "unknown"
163
222
 
164
- # Auth status
165
- auth_status = "🔓" if self.is_authenticated() else "🔒"
166
-
167
- return f"[dim]model: {provider}/{model} {auth_status}[/dim]"
223
+ return f"[dim]model: {provider}/{model}[/dim]"
168
224
 
169
225
  async def run(self):
170
226
  """Run the enhanced REPL."""
@@ -190,7 +246,8 @@ class EnhancedHanzoREPL:
190
246
  # Get input with simple prompt
191
247
  command = await self.session.prompt_async(
192
248
  self.get_prompt(),
193
- completer=completer
249
+ completer=completer,
250
+ vi_mode=True # Enable vi mode for better navigation
194
251
  )
195
252
 
196
253
  if not command.strip():
@@ -305,11 +362,11 @@ class EnhancedHanzoREPL:
305
362
  self.console.print(f"\n[dim]Last login: {self.auth['last_login']}[/dim]")
306
363
 
307
364
  async def change_model(self, args: str = ""):
308
- """Change the current model."""
365
+ """Change the current model or tool."""
309
366
  if not args:
310
367
  # Show model selection menu
311
368
  await self.list_models("")
312
- self.console.print("\n[cyan]Enter model name or number:[/cyan]")
369
+ self.console.print("\n[cyan]Enter model/tool name or number:[/cyan]")
313
370
 
314
371
  # Get selection
315
372
  try:
@@ -317,41 +374,87 @@ class EnhancedHanzoREPL:
317
374
 
318
375
  # Handle numeric selection
319
376
  if selection.isdigit():
320
- models_list = list(self.MODELS.keys())
321
- idx = int(selection) - 1
322
- if 0 <= idx < len(models_list):
323
- args = models_list[idx]
377
+ num = int(selection)
378
+
379
+ # Check if it's a tool selection
380
+ if self.detected_tools and num <= len(self.detected_tools):
381
+ tool = self.detected_tools[num - 1]
382
+ args = f"tool:{tool.name}"
324
383
  else:
325
- self.console.print("[red]Invalid selection[/red]")
326
- return
384
+ # It's a model selection
385
+ model_idx = num - len(self.detected_tools) - 1 if self.detected_tools else num - 1
386
+ models_list = list(self.MODELS.keys())
387
+ if 0 <= model_idx < len(models_list):
388
+ args = models_list[model_idx]
389
+ else:
390
+ self.console.print("[red]Invalid selection[/red]")
391
+ return
327
392
  else:
328
393
  args = selection
329
394
  except (KeyboardInterrupt, EOFError):
330
395
  return
331
396
 
332
- # Validate model
333
- if args not in self.MODELS and not args.startswith("local:"):
334
- self.console.print(f"[red]Unknown model: {args}[/red]")
335
- self.console.print("[dim]Use /models to see available models[/dim]")
336
- return
337
-
338
- # Change model
339
- self.current_model = args
340
- self.config["default_model"] = args
341
- self.save_config()
397
+ # Check if it's a tool
398
+ if args.startswith("tool:") or args in [t.name for t in self.detected_tools] if self.detected_tools else False:
399
+ # Handle tool selection
400
+ tool_name = args.replace("tool:", "") if args.startswith("tool:") else args
401
+
402
+ # Find the tool
403
+ tool = None
404
+ for t in self.detected_tools:
405
+ if t.name == tool_name or t.display_name.lower() == tool_name.lower():
406
+ tool = t
407
+ break
408
+
409
+ if tool:
410
+ self.current_model = f"tool:{tool.name}"
411
+ self.current_tool = tool
412
+ self.config["default_model"] = self.current_model
413
+ self.save_config()
414
+ self.console.print(f"[green]✅ Switched to {tool.display_name}[/green]")
415
+ else:
416
+ self.console.print(f"[red]Tool not found: {tool_name}[/red]")
417
+ self.console.print("[dim]Use /tools to see available tools[/dim]")
418
+
419
+ # Regular model
420
+ elif args in self.MODELS or args.startswith("local:"):
421
+ self.current_model = args
422
+ self.current_tool = None
423
+ self.config["default_model"] = args
424
+ self.save_config()
425
+
426
+ model_name = self.MODELS.get(args, args)
427
+ self.console.print(f"[green]✅ Switched to {model_name}[/green]")
342
428
 
343
- model_name = self.MODELS.get(args, args)
344
- self.console.print(f"[green] Switched to {model_name}[/green]")
429
+ else:
430
+ self.console.print(f"[red]Unknown model or tool: {args}[/red]")
431
+ self.console.print("[dim]Use /models or /tools to see available options[/dim]")
345
432
 
433
+ async def list_tools(self, args: str = ""):
434
+ """List available AI tools."""
435
+ if self.tool_detector:
436
+ self.tool_detector.show_available_tools()
437
+ else:
438
+ self.console.print("[yellow]Tool detection not available[/yellow]")
439
+
346
440
  async def list_models(self, args: str = ""):
347
441
  """List available models."""
348
- table = Table(title="Available Models", box=box.ROUNDED)
442
+ # Show tools first if available
443
+ if self.detected_tools:
444
+ self.console.print("[bold cyan]AI Coding Assistants (Detected):[/bold cyan]")
445
+ for i, tool in enumerate(self.detected_tools, 1):
446
+ marker = "→" if self.current_model == f"tool:{tool.name}" else " "
447
+ self.console.print(f" {marker} {i}. {tool.display_name} ({tool.provider})")
448
+ self.console.print()
449
+
450
+ table = Table(title="Language Models", box=box.ROUNDED)
349
451
  table.add_column("#", style="dim")
350
452
  table.add_column("Model ID", style="cyan")
351
453
  table.add_column("Name", style="white")
352
454
  table.add_column("Provider", style="yellow")
353
455
 
354
- for i, (model_id, model_name) in enumerate(self.MODELS.items(), 1):
456
+ start_idx = len(self.detected_tools) + 1 if self.detected_tools else 1
457
+ for i, (model_id, model_name) in enumerate(self.MODELS.items(), start_idx):
355
458
  # Extract provider
356
459
  if model_id.startswith("gpt"):
357
460
  provider = "OpenAI"
@@ -449,6 +552,10 @@ class EnhancedHanzoREPL:
449
552
  ## Slash Commands:
450
553
  - `/model [name]` - Change AI model (or `/m`)
451
554
  - `/models` - List available models
555
+ - `/tools` - List available AI tools
556
+ - `/quick` - Quick model selector (arrow keys)
557
+ - `/tasks` - Show background tasks
558
+ - `/kill [id]` - Kill background task
452
559
  - `/status` - Show system status (or `/s`)
453
560
  - `/login` - Login to Hanzo Cloud
454
561
  - `/logout` - Logout from Hanzo
@@ -457,6 +564,11 @@ class EnhancedHanzoREPL:
457
564
  - `/clear` - Clear screen (or `/c`)
458
565
  - `/quit` - Exit REPL (or `/q`)
459
566
 
567
+ ## Quick Model Selection:
568
+ - Press ↓ arrow key for quick model selector
569
+ - Use ↑/↓ to navigate, Enter to select
570
+ - Esc to cancel
571
+
460
572
  ## Model Selection:
461
573
  - Use `/model gpt-4` to switch to GPT-4
462
574
  - Use `/model 3` to select model by number
@@ -508,6 +620,69 @@ class EnhancedHanzoREPL:
508
620
  self.console.print(f"[red]Error executing command: {e}[/red]")
509
621
 
510
622
  async def chat_with_ai(self, message: str):
511
- """Chat with AI using current model."""
512
- # Default to cloud mode to avoid needing local server
513
- await self.execute_command("ask", f"--cloud --model {self.current_model} {message}")
623
+ """Chat with AI using current model or tool."""
624
+ # Check if using a tool
625
+ if self.current_model.startswith("tool:") and self.current_tool:
626
+ # Use the detected tool directly
627
+ self.console.print(f"[dim]Using {self.current_tool.display_name}...[/dim]")
628
+
629
+ success, output = self.tool_detector.execute_with_tool(self.current_tool, message)
630
+
631
+ if success:
632
+ self.console.print(output)
633
+ else:
634
+ # Fallback to regular model
635
+ self.console.print(f"[yellow]{self.current_tool.display_name} failed, trying cloud model...[/yellow]")
636
+ await self.execute_command("ask", f"--cloud --model gpt-3.5-turbo {message}")
637
+ else:
638
+ # Use regular model through hanzo ask
639
+ await self.execute_command("ask", f"--cloud --model {self.current_model} {message}")
640
+
641
+ async def quick_model_select(self, args: str = ""):
642
+ """Quick model selector with arrow keys."""
643
+ if not QuickModelSelector:
644
+ self.console.print("[yellow]Quick selector not available[/yellow]")
645
+ return
646
+
647
+ # Prepare tools and models
648
+ tools = [(f"tool:{t.name}", t.display_name) for t in self.detected_tools] if self.detected_tools else []
649
+ models = list(self.MODELS.items())
650
+
651
+ selector = QuickModelSelector(models, tools, self.current_model)
652
+ selected = await selector.run()
653
+
654
+ if selected:
655
+ # Change to selected model
656
+ await self.change_model(selected)
657
+
658
+ async def show_tasks(self, args: str = ""):
659
+ """Show background tasks."""
660
+ if self.task_manager:
661
+ self.task_manager.list_tasks()
662
+ else:
663
+ self.console.print("[yellow]Task manager not available[/yellow]")
664
+
665
+ async def kill_task(self, args: str = ""):
666
+ """Kill a background task."""
667
+ if not self.task_manager:
668
+ self.console.print("[yellow]Task manager not available[/yellow]")
669
+ return
670
+
671
+ if args:
672
+ if args.lower() == "all":
673
+ self.task_manager.kill_all()
674
+ else:
675
+ self.task_manager.kill_task(args)
676
+ else:
677
+ # Show tasks and prompt for selection
678
+ self.task_manager.list_tasks()
679
+ self.console.print("\n[cyan]Enter task ID to kill (or 'all' for all tasks):[/cyan]")
680
+ try:
681
+ task_id = await self.session.prompt_async("> ")
682
+ if task_id:
683
+ if task_id.lower() == "all":
684
+ self.task_manager.kill_all()
685
+ else:
686
+ self.task_manager.kill_task(task_id)
687
+ except (KeyboardInterrupt, EOFError):
688
+ pass
@@ -0,0 +1,166 @@
1
+ """Quick model selector with arrow key navigation."""
2
+
3
+ from typing import List, Optional, Tuple
4
+ from prompt_toolkit import Application
5
+ from prompt_toolkit.key_binding import KeyBindings
6
+ from prompt_toolkit.layout.containers import HSplit, Window
7
+ from prompt_toolkit.layout.controls import FormattedTextControl
8
+ from prompt_toolkit.layout.layout import Layout
9
+ from prompt_toolkit.widgets import Label
10
+ from rich.console import Console
11
+
12
+
13
+ class QuickModelSelector:
14
+ """Quick model selector with arrow navigation."""
15
+
16
+ def __init__(self, models: List[Tuple[str, str]], tools: List[Tuple[str, str]], current: str):
17
+ self.models = models
18
+ self.tools = tools
19
+ self.current = current
20
+ self.all_items = tools + models # Tools first, then models
21
+ self.selected_index = 0
22
+
23
+ # Find current selection
24
+ for i, (item_id, _) in enumerate(self.all_items):
25
+ if item_id == current:
26
+ self.selected_index = i
27
+ break
28
+
29
+ def get_display_lines(self) -> List[str]:
30
+ """Get display lines for the selector."""
31
+ lines = []
32
+
33
+ if self.tools:
34
+ lines.append("AI Coding Assistants:")
35
+ for i, (tool_id, tool_name) in enumerate(self.tools):
36
+ marker = "→ " if i == self.selected_index else " "
37
+ lines.append(f"{marker}{tool_name}")
38
+
39
+ if self.models:
40
+ if self.tools:
41
+ lines.append("") # Empty line
42
+ lines.append("Language Models:")
43
+
44
+ tool_count = len(self.tools)
45
+ for i, (model_id, model_name) in enumerate(self.models):
46
+ actual_idx = tool_count + i
47
+ marker = "→ " if actual_idx == self.selected_index else " "
48
+ lines.append(f"{marker}{model_name}")
49
+
50
+ return lines
51
+
52
+ def move_up(self):
53
+ """Move selection up."""
54
+ if self.selected_index > 0:
55
+ self.selected_index -= 1
56
+
57
+ def move_down(self):
58
+ """Move selection down."""
59
+ if self.selected_index < len(self.all_items) - 1:
60
+ self.selected_index += 1
61
+
62
+ def get_selected(self) -> Tuple[str, str]:
63
+ """Get the selected item."""
64
+ if 0 <= self.selected_index < len(self.all_items):
65
+ return self.all_items[self.selected_index]
66
+ return None, None
67
+
68
+ async def run(self) -> Optional[str]:
69
+ """Run the selector and return selected model/tool ID."""
70
+ kb = KeyBindings()
71
+
72
+ @kb.add('up')
73
+ def _(event):
74
+ self.move_up()
75
+ event.app.invalidate()
76
+
77
+ @kb.add('down')
78
+ def _(event):
79
+ self.move_down()
80
+ event.app.invalidate()
81
+
82
+ @kb.add('enter')
83
+ def _(event):
84
+ event.app.exit(result=self.get_selected()[0])
85
+
86
+ @kb.add('c-c')
87
+ @kb.add('escape')
88
+ def _(event):
89
+ event.app.exit(result=None)
90
+
91
+ def get_text():
92
+ lines = self.get_display_lines()
93
+ lines.append("")
94
+ lines.append("↑/↓: Navigate Enter: Select Esc: Cancel")
95
+ return "\n".join(lines)
96
+
97
+ layout = Layout(
98
+ Window(
99
+ FormattedTextControl(get_text),
100
+ wrap_lines=False
101
+ )
102
+ )
103
+
104
+ app = Application(
105
+ layout=layout,
106
+ key_bindings=kb,
107
+ full_screen=False,
108
+ mouse_support=True
109
+ )
110
+
111
+ return await app.run_async()
112
+
113
+
114
+ class BackgroundTaskManager:
115
+ """Manage background tasks."""
116
+
117
+ def __init__(self, console: Optional[Console] = None):
118
+ self.console = console or Console()
119
+ self.tasks = {} # task_id -> process
120
+ self.next_id = 1
121
+
122
+ def add_task(self, name: str, process):
123
+ """Add a background task."""
124
+ task_id = f"task_{self.next_id}"
125
+ self.next_id += 1
126
+ self.tasks[task_id] = {
127
+ "name": name,
128
+ "process": process,
129
+ "started": True
130
+ }
131
+ return task_id
132
+
133
+ def list_tasks(self):
134
+ """List all background tasks."""
135
+ if not self.tasks:
136
+ self.console.print("[dim]No background tasks running[/dim]")
137
+ return
138
+
139
+ self.console.print("[bold]Background Tasks:[/bold]")
140
+ for task_id, task in self.tasks.items():
141
+ status = "🟢 Running" if task["process"].poll() is None else "🔴 Stopped"
142
+ self.console.print(f" {task_id}: {task['name']} - {status}")
143
+
144
+ def kill_task(self, task_id: str):
145
+ """Kill a background task."""
146
+ if task_id in self.tasks:
147
+ task = self.tasks[task_id]
148
+ if task["process"].poll() is None:
149
+ task["process"].terminate()
150
+ self.console.print(f"[yellow]Terminated {task_id}: {task['name']}[/yellow]")
151
+ else:
152
+ self.console.print(f"[dim]Task {task_id} already stopped[/dim]")
153
+ del self.tasks[task_id]
154
+ else:
155
+ self.console.print(f"[red]Task {task_id} not found[/red]")
156
+
157
+ def kill_all(self):
158
+ """Kill all background tasks."""
159
+ if not self.tasks:
160
+ self.console.print("[dim]No tasks to kill[/dim]")
161
+ return
162
+
163
+ for task_id in list(self.tasks.keys()):
164
+ self.kill_task(task_id)
165
+
166
+ self.console.print("[green]All tasks terminated[/green]")
@@ -0,0 +1,5 @@
1
+ """AI tools detection and management."""
2
+
3
+ from .detector import ToolDetector, AITool
4
+
5
+ __all__ = ["ToolDetector", "AITool"]
@@ -0,0 +1,387 @@
1
+ """Detect available AI coding tools and assistants."""
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import httpx
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Tuple
9
+ from dataclasses import dataclass
10
+
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich import box
14
+
15
+
16
+ @dataclass
17
+ class AITool:
18
+ """Represents an AI coding tool."""
19
+ name: str
20
+ command: str
21
+ display_name: str
22
+ provider: str
23
+ priority: int # Lower is higher priority
24
+ check_command: Optional[str] = None
25
+ env_var: Optional[str] = None
26
+ api_endpoint: Optional[str] = None
27
+ detected: bool = False
28
+ version: Optional[str] = None
29
+ path: Optional[str] = None
30
+
31
+
32
+ class ToolDetector:
33
+ """Detect and manage available AI coding tools."""
34
+
35
+ # Define available tools with priority order
36
+ TOOLS = [
37
+ # Hanzo Local Node - highest priority for privacy and local control
38
+ AITool(
39
+ name="hanzod",
40
+ command="hanzo node",
41
+ display_name="Hanzo Node (Local Private AI)",
42
+ provider="hanzo-local",
43
+ priority=0, # Highest priority - local and private
44
+ check_command=None, # Check via API endpoint
45
+ api_endpoint="http://localhost:8000/health",
46
+ env_var=None
47
+ ),
48
+ AITool(
49
+ name="hanzo-router",
50
+ command="hanzo router",
51
+ display_name="Hanzo Router (LLM Proxy)",
52
+ provider="hanzo-router",
53
+ priority=1,
54
+ check_command=None,
55
+ api_endpoint="http://localhost:4000/health",
56
+ env_var=None
57
+ ),
58
+ AITool(
59
+ name="claude-code",
60
+ command="claude",
61
+ display_name="Claude Code",
62
+ provider="anthropic",
63
+ priority=2,
64
+ check_command="claude --version",
65
+ env_var="ANTHROPIC_API_KEY"
66
+ ),
67
+ AITool(
68
+ name="hanzo-dev",
69
+ command="hanzo dev",
70
+ display_name="Hanzo Dev (Native)",
71
+ provider="hanzo",
72
+ priority=3,
73
+ check_command="hanzo --version",
74
+ env_var="HANZO_API_KEY"
75
+ ),
76
+ AITool(
77
+ name="openai-codex",
78
+ command="openai",
79
+ display_name="OpenAI Codex",
80
+ provider="openai",
81
+ priority=4,
82
+ check_command="openai --version",
83
+ env_var="OPENAI_API_KEY"
84
+ ),
85
+ AITool(
86
+ name="gemini-cli",
87
+ command="gemini",
88
+ display_name="Gemini CLI",
89
+ provider="google",
90
+ priority=5,
91
+ check_command="gemini --version",
92
+ env_var="GEMINI_API_KEY"
93
+ ),
94
+ AITool(
95
+ name="grok-cli",
96
+ command="grok",
97
+ display_name="Grok CLI",
98
+ provider="xai",
99
+ priority=6,
100
+ check_command="grok --version",
101
+ env_var="GROK_API_KEY"
102
+ ),
103
+ AITool(
104
+ name="openhands",
105
+ command="openhands",
106
+ display_name="OpenHands CLI",
107
+ provider="openhands",
108
+ priority=7,
109
+ check_command="openhands --version",
110
+ env_var=None
111
+ ),
112
+ AITool(
113
+ name="cursor",
114
+ command="cursor",
115
+ display_name="Cursor AI",
116
+ provider="cursor",
117
+ priority=8,
118
+ check_command="cursor --version",
119
+ env_var=None
120
+ ),
121
+ AITool(
122
+ name="codeium",
123
+ command="codeium",
124
+ display_name="Codeium",
125
+ provider="codeium",
126
+ priority=9,
127
+ check_command="codeium --version",
128
+ env_var="CODEIUM_API_KEY"
129
+ ),
130
+ AITool(
131
+ name="aider",
132
+ command="aider",
133
+ display_name="Aider",
134
+ provider="aider",
135
+ priority=10,
136
+ check_command="aider --version",
137
+ env_var=None
138
+ ),
139
+ AITool(
140
+ name="continue",
141
+ command="continue",
142
+ display_name="Continue Dev",
143
+ provider="continue",
144
+ priority=11,
145
+ check_command="continue --version",
146
+ env_var=None
147
+ )
148
+ ]
149
+
150
+ def __init__(self, console: Optional[Console] = None):
151
+ self.console = console or Console()
152
+ self.detected_tools: List[AITool] = []
153
+
154
+ def detect_all(self) -> List[AITool]:
155
+ """Detect all available AI tools."""
156
+ self.detected_tools = []
157
+
158
+ for tool in self.TOOLS:
159
+ if self.detect_tool(tool):
160
+ self.detected_tools.append(tool)
161
+
162
+ # Sort by priority
163
+ self.detected_tools.sort(key=lambda t: t.priority)
164
+ return self.detected_tools
165
+
166
+ def detect_tool(self, tool: AITool) -> bool:
167
+ """Detect if a specific tool is available."""
168
+ # Check API endpoint first (for services like hanzod)
169
+ if tool.api_endpoint:
170
+ try:
171
+ response = httpx.get(tool.api_endpoint, timeout=1.0)
172
+ if response.status_code == 200:
173
+ tool.detected = True
174
+ tool.version = "Running"
175
+
176
+ # Special handling for Hanzo services
177
+ if tool.name == "hanzod":
178
+ # Check if models are loaded
179
+ try:
180
+ models_response = httpx.get("http://localhost:8000/models", timeout=1.0)
181
+ if models_response.status_code == 200:
182
+ models = models_response.json()
183
+ if models:
184
+ tool.version = f"Running ({len(models)} models)"
185
+ except:
186
+ pass
187
+
188
+ return True
189
+ except:
190
+ pass
191
+
192
+ # Check if command exists
193
+ if tool.command:
194
+ tool.path = shutil.which(tool.command.split()[0])
195
+ if tool.path:
196
+ tool.detected = True
197
+
198
+ # Try to get version
199
+ if tool.check_command:
200
+ try:
201
+ result = subprocess.run(
202
+ tool.check_command.split(),
203
+ capture_output=True,
204
+ text=True,
205
+ timeout=2
206
+ )
207
+ if result.returncode == 0:
208
+ tool.version = result.stdout.strip().split()[-1]
209
+ except:
210
+ pass
211
+
212
+ return True
213
+
214
+ # Check environment variable as fallback
215
+ if tool.env_var and os.getenv(tool.env_var):
216
+ tool.detected = True
217
+ return True
218
+
219
+ return False
220
+
221
+ def get_default_tool(self) -> Optional[AITool]:
222
+ """Get the default tool based on priority and availability."""
223
+ if not self.detected_tools:
224
+ self.detect_all()
225
+
226
+ if self.detected_tools:
227
+ return self.detected_tools[0]
228
+ return None
229
+
230
+ def get_tool_by_name(self, name: str) -> Optional[AITool]:
231
+ """Get a specific tool by name."""
232
+ for tool in self.TOOLS:
233
+ if tool.name == name or tool.display_name.lower() == name.lower():
234
+ if self.detect_tool(tool):
235
+ return tool
236
+ return None
237
+
238
+ def show_available_tools(self):
239
+ """Display available tools in a table."""
240
+ self.detect_all()
241
+
242
+ table = Table(title="Available AI Coding Tools", box=box.ROUNDED)
243
+ table.add_column("#", style="dim")
244
+ table.add_column("Tool", style="cyan")
245
+ table.add_column("Provider", style="yellow")
246
+ table.add_column("Status", style="green")
247
+ table.add_column("Version", style="blue")
248
+ table.add_column("Priority", style="magenta")
249
+
250
+ for i, tool in enumerate(self.TOOLS, 1):
251
+ status = "✅ Available" if tool.detected else "❌ Not Found"
252
+ version = tool.version or "Unknown" if tool.detected else "-"
253
+
254
+ # Highlight the default tool
255
+ if tool.detected and tool == self.detected_tools[0] if self.detected_tools else False:
256
+ table.add_row(
257
+ str(i),
258
+ f"[bold green]→ {tool.display_name}[/bold green]",
259
+ tool.provider,
260
+ status,
261
+ version,
262
+ str(tool.priority)
263
+ )
264
+ else:
265
+ table.add_row(
266
+ str(i),
267
+ tool.display_name,
268
+ tool.provider,
269
+ status,
270
+ version,
271
+ str(tool.priority)
272
+ )
273
+
274
+ self.console.print(table)
275
+
276
+ if self.detected_tools:
277
+ default = self.detected_tools[0]
278
+ self.console.print(f"\n[green]Default tool: {default.display_name}[/green]")
279
+
280
+ # Special message for Hanzo Node
281
+ if default.name == "hanzod":
282
+ self.console.print("[cyan]🔒 Using local private AI - your data stays on your machine[/cyan]")
283
+ self.console.print("[dim]Manage models with: hanzo node models[/dim]")
284
+ else:
285
+ self.console.print("\n[yellow]No AI coding tools detected.[/yellow]")
286
+ self.console.print("[dim]Start Hanzo Node for local AI: hanzo node start[/dim]")
287
+ self.console.print("[dim]Or install Claude Code, OpenAI CLI, etc.[/dim]")
288
+
289
+ def get_tool_command(self, tool: AITool, prompt: str) -> List[str]:
290
+ """Get the command to execute for a tool with a prompt."""
291
+ if tool.name == "hanzod":
292
+ # Use the local Hanzo node API
293
+ return ["hanzo", "ask", "--local", prompt]
294
+ elif tool.name == "hanzo-router":
295
+ # Use the router proxy
296
+ return ["hanzo", "ask", "--router", prompt]
297
+ elif tool.name == "claude-code":
298
+ return ["claude", prompt]
299
+ elif tool.name == "hanzo-dev":
300
+ return ["hanzo", "dev", "--prompt", prompt]
301
+ elif tool.name == "openai-codex":
302
+ return ["openai", "api", "completions.create", "-m", "code-davinci-002", "-p", prompt]
303
+ elif tool.name == "gemini-cli":
304
+ return ["gemini", "generate", "--prompt", prompt]
305
+ elif tool.name == "grok-cli":
306
+ return ["grok", "complete", prompt]
307
+ elif tool.name == "openhands":
308
+ return ["openhands", "run", prompt]
309
+ elif tool.name == "cursor":
310
+ return ["cursor", "--prompt", prompt]
311
+ elif tool.name == "aider":
312
+ return ["aider", "--message", prompt]
313
+ else:
314
+ return [tool.command, prompt]
315
+
316
+ def execute_with_tool(self, tool: AITool, prompt: str) -> Tuple[bool, str]:
317
+ """Execute a prompt with a specific tool."""
318
+ try:
319
+ # Special handling for Hanzo services
320
+ if tool.name == "hanzod":
321
+ # Use the local API directly
322
+ try:
323
+ response = httpx.post(
324
+ "http://localhost:8000/chat/completions",
325
+ json={
326
+ "messages": [{"role": "user", "content": prompt}],
327
+ "stream": False
328
+ },
329
+ timeout=30.0
330
+ )
331
+ if response.status_code == 200:
332
+ result = response.json()
333
+ return True, result.get("choices", [{}])[0].get("message", {}).get("content", "")
334
+ except Exception as e:
335
+ return False, f"Hanzo Node error: {e}"
336
+
337
+ elif tool.name == "hanzo-router":
338
+ # Use the router API
339
+ try:
340
+ response = httpx.post(
341
+ "http://localhost:4000/chat/completions",
342
+ json={
343
+ "messages": [{"role": "user", "content": prompt}],
344
+ "model": "gpt-3.5-turbo", # Router will route to best available
345
+ "stream": False
346
+ },
347
+ timeout=30.0
348
+ )
349
+ if response.status_code == 200:
350
+ result = response.json()
351
+ return True, result.get("choices", [{}])[0].get("message", {}).get("content", "")
352
+ except Exception as e:
353
+ return False, f"Router error: {e}"
354
+
355
+ # Default command execution
356
+ command = self.get_tool_command(tool, prompt)
357
+ result = subprocess.run(
358
+ command,
359
+ capture_output=True,
360
+ text=True,
361
+ timeout=30
362
+ )
363
+
364
+ if result.returncode == 0:
365
+ return True, result.stdout
366
+ else:
367
+ return False, result.stderr or "Command failed"
368
+ except subprocess.TimeoutExpired:
369
+ return False, "Command timed out"
370
+ except Exception as e:
371
+ return False, str(e)
372
+
373
+ def execute_with_fallback(self, prompt: str) -> Tuple[bool, str, AITool]:
374
+ """Execute with fallback through available tools."""
375
+ if not self.detected_tools:
376
+ self.detect_all()
377
+
378
+ for tool in self.detected_tools:
379
+ self.console.print(f"[dim]Trying {tool.display_name}...[/dim]")
380
+ success, output = self.execute_with_tool(tool, prompt)
381
+
382
+ if success:
383
+ return True, output, tool
384
+ else:
385
+ self.console.print(f"[yellow]{tool.display_name} failed: {output}[/yellow]")
386
+
387
+ return False, "No available tools could handle the request", None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hanzo
3
- Version: 0.3.25
3
+ Version: 0.3.27
4
4
  Summary: Hanzo AI - Complete AI Infrastructure Platform with CLI, Router, MCP, and Agent Runtime
5
5
  Project-URL: Homepage, https://hanzo.ai
6
6
  Project-URL: Repository, https://github.com/hanzoai/python-sdk
@@ -1,4 +1,4 @@
1
- hanzo/__init__.py,sha256=wyqd5nRy826-cL9Mow11ZIJKjPWDFHpltYUhi9M7Gqw,185
1
+ hanzo/__init__.py,sha256=QPRAp9EYB1L4Mii0Qoe3JYADXxMEJdaSyUg8f4vg7mI,185
2
2
  hanzo/__main__.py,sha256=F3Vz0Ty3bdAj_8oxyETMIqxlmNRnJOAFB1XPxbyfouI,105
3
3
  hanzo/base_agent.py,sha256=ojPaSgFETYl7iARWnNpg8eyAt7sg8eKhn9xZThyvxRA,15324
4
4
  hanzo/batch_orchestrator.py,sha256=vn6n5i9gTfZ4DtowFDd5iWgYKjgNTioIomkffKbipSM,35827
@@ -27,9 +27,12 @@ hanzo/commands/router.py,sha256=kB8snUM82cFk3znjFvs3jOJGqv5giKn8DiTkdbXnWYU,5332
27
27
  hanzo/commands/tools.py,sha256=fG27wRweVmaFJowBpmwp5PgkRUtIF8bIlu_hGWr69Ss,10393
28
28
  hanzo/interactive/__init__.py,sha256=ENHkGOqu-JYI05lqoOKDczJGl96oq6nM476EPhflAbI,74
29
29
  hanzo/interactive/dashboard.py,sha256=XB5H_PMlReriCip-wW9iuUiJQOAtSATFG8EyhhFhItU,3842
30
- hanzo/interactive/enhanced_repl.py,sha256=ZyrP22gvOGE6J3rOboW19RwAZXVid6YYns9rzNbSV4c,17952
30
+ hanzo/interactive/enhanced_repl.py,sha256=dmYun_rtWswoxVIVGamKASMJVS7QK5-TkuS_lIVzb4w,25493
31
+ hanzo/interactive/model_selector.py,sha256=4HcXvr8AI8Y5IttMH7Dhb8M0vqzP5y3S5kQrQmopYuw,5519
31
32
  hanzo/interactive/repl.py,sha256=PXpRw1Cfqdqy1pQsKLqz9AwKJBFZ_Y758MpDlJIb9ao,6938
32
33
  hanzo/router/__init__.py,sha256=_cRG9nHC_wwq17iVYZSUNBYiJDdByfLDVEuIQn5-ePM,978
34
+ hanzo/tools/__init__.py,sha256=SsgmDvw5rO--NF4vKL9tV3O4WCNEl9aAIuqyTGSZ4RQ,122
35
+ hanzo/tools/detector.py,sha256=qwVc1fIDt2lDuqFqjhTVCnToRka91n125mpOpsPCfTU,14054
33
36
  hanzo/ui/__init__.py,sha256=Ea22ereOm5Y0DDfyonA6qsO9Qkzofzd1CUE-VGW2lqw,241
34
37
  hanzo/ui/inline_startup.py,sha256=7Y5dwqzt-L1J0F9peyqJ8XZgjHSua2nkItDTrLlBnhU,4265
35
38
  hanzo/ui/startup.py,sha256=s7gP1QleQEIoCS1K0XBY7d6aufnwhicRLZDL7ej8ZZY,12235
@@ -37,7 +40,7 @@ hanzo/utils/__init__.py,sha256=5RRwKI852vp8smr4xCRgeKfn7dLEnHbdXGfVYTZ5jDQ,69
37
40
  hanzo/utils/config.py,sha256=FD_LoBpcoF5dgJ7WL4o6LDp2pdOy8kS-dJ6iRO2GcGM,4728
38
41
  hanzo/utils/net_check.py,sha256=YFbJ65SzfDYHkHLZe3n51VhId1VI3zhyx8p6BM-l6jE,3017
39
42
  hanzo/utils/output.py,sha256=W0j3psF07vJiX4s02gbN4zYWfbKNsb8TSIoagBSf5vA,2704
40
- hanzo-0.3.25.dist-info/METADATA,sha256=QyybsQ7W3TJVStEaHWs3ZYMEHiVeCndtsBqlsLge9Ck,6061
41
- hanzo-0.3.25.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
42
- hanzo-0.3.25.dist-info/entry_points.txt,sha256=pQLPMdqOXU_2BfTcMDhkqTCDNk_H6ApvYuSaWcuQOOw,171
43
- hanzo-0.3.25.dist-info/RECORD,,
43
+ hanzo-0.3.27.dist-info/METADATA,sha256=lpeix1C33n9JcKV7DAHF1Iu76YlLADANcA2uhE6LVY8,6061
44
+ hanzo-0.3.27.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
+ hanzo-0.3.27.dist-info/entry_points.txt,sha256=pQLPMdqOXU_2BfTcMDhkqTCDNk_H6ApvYuSaWcuQOOw,171
46
+ hanzo-0.3.27.dist-info/RECORD,,
File without changes