hanzo 0.3.26__py3-none-any.whl → 0.3.28__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.26"
3
+ __version__ = "0.3.28"
4
4
  __all__ = ["main", "cli", "__version__"]
5
5
 
6
6
  from .cli import cli, main
@@ -32,6 +32,11 @@ except ImportError:
32
32
  QuickModelSelector = None
33
33
  BackgroundTaskManager = None
34
34
 
35
+ try:
36
+ from .todo_manager import TodoManager
37
+ except ImportError:
38
+ TodoManager = None
39
+
35
40
 
36
41
  class EnhancedHanzoREPL:
37
42
  """Enhanced REPL with model selection and authentication."""
@@ -99,6 +104,9 @@ class EnhancedHanzoREPL:
99
104
  # Initialize background task manager
100
105
  self.task_manager = BackgroundTaskManager(console) if BackgroundTaskManager else None
101
106
 
107
+ # Initialize todo manager
108
+ self.todo_manager = TodoManager(console) if TodoManager else None
109
+
102
110
  # Detect available tools and set default
103
111
  if self.tool_detector:
104
112
  self.detected_tools = self.tool_detector.detect_all()
@@ -139,6 +147,8 @@ class EnhancedHanzoREPL:
139
147
  "tasks": self.show_tasks,
140
148
  "kill": self.kill_task,
141
149
  "quick": self.quick_model_select,
150
+ "todo": self.manage_todos,
151
+ "todos": self.manage_todos, # Alias
142
152
  }
143
153
 
144
154
  self.running = False
@@ -295,6 +305,9 @@ class EnhancedHanzoREPL:
295
305
  "models": "models",
296
306
  "login": "login",
297
307
  "logout": "logout",
308
+ "todo": "todo",
309
+ "todos": "todos",
310
+ "t": "todo", # Shortcut for todo
298
311
  }
299
312
 
300
313
  mapped_cmd = slash_map.get(cmd, cmd)
@@ -550,6 +563,7 @@ class EnhancedHanzoREPL:
550
563
  # Hanzo Enhanced REPL
551
564
 
552
565
  ## Slash Commands:
566
+ - `/todo [cmd]` - Manage todos (see `/todo help`)
553
567
  - `/model [name]` - Change AI model (or `/m`)
554
568
  - `/models` - List available models
555
569
  - `/tools` - List available AI tools
@@ -685,4 +699,215 @@ class EnhancedHanzoREPL:
685
699
  else:
686
700
  self.task_manager.kill_task(task_id)
687
701
  except (KeyboardInterrupt, EOFError):
688
- pass
702
+ pass
703
+
704
+ async def manage_todos(self, args: str = ""):
705
+ """Manage todos."""
706
+ if not self.todo_manager:
707
+ self.console.print("[yellow]Todo manager not available[/yellow]")
708
+ return
709
+
710
+ # Parse command
711
+ parts = args.strip().split(maxsplit=1)
712
+
713
+ if not parts:
714
+ # Show todos
715
+ self.todo_manager.display_todos()
716
+ return
717
+
718
+ subcommand = parts[0].lower()
719
+ rest = parts[1] if len(parts) > 1 else ""
720
+
721
+ # Handle subcommands
722
+ if subcommand in ["add", "a", "+"]:
723
+ # Add todo
724
+ if rest:
725
+ # Quick add
726
+ try:
727
+ todo = self.todo_manager.quick_add(rest)
728
+ self.console.print(f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]")
729
+ except ValueError as e:
730
+ self.console.print(f"[red]Error: {e}[/red]")
731
+ else:
732
+ # Interactive add
733
+ await self.add_todo_interactive()
734
+
735
+ elif subcommand in ["list", "ls", "l"]:
736
+ # List todos with optional filter
737
+ filter_parts = rest.split()
738
+ status = None
739
+ priority = None
740
+ tag = None
741
+
742
+ for i in range(0, len(filter_parts), 2):
743
+ if i + 1 < len(filter_parts):
744
+ key = filter_parts[i]
745
+ value = filter_parts[i + 1]
746
+
747
+ if key in ["status", "s"]:
748
+ status = value
749
+ elif key in ["priority", "p"]:
750
+ priority = value
751
+ elif key in ["tag", "t"]:
752
+ tag = value
753
+
754
+ todos = self.todo_manager.list_todos(status=status, priority=priority, tag=tag)
755
+ title = "Filtered Todos" if (status or priority or tag) else "All Todos"
756
+ self.todo_manager.display_todos(todos, title)
757
+
758
+ elif subcommand in ["done", "d", "complete", "finish"]:
759
+ # Mark as done
760
+ if rest:
761
+ todo = self.todo_manager.update_todo(rest, status="done")
762
+ if todo:
763
+ self.console.print(f"[green]✅ Marked as done: {todo.title}[/green]")
764
+ else:
765
+ self.console.print(f"[red]Todo not found: {rest}[/red]")
766
+ else:
767
+ self.console.print("[yellow]Usage: /todo done <id>[/yellow]")
768
+
769
+ elif subcommand in ["start", "begin", "progress"]:
770
+ # Mark as in progress
771
+ if rest:
772
+ todo = self.todo_manager.update_todo(rest, status="in_progress")
773
+ if todo:
774
+ self.console.print(f"[cyan]🔄 Started: {todo.title}[/cyan]")
775
+ else:
776
+ self.console.print(f"[red]Todo not found: {rest}[/red]")
777
+ else:
778
+ self.console.print("[yellow]Usage: /todo start <id>[/yellow]")
779
+
780
+ elif subcommand in ["cancel", "x"]:
781
+ # Cancel todo
782
+ if rest:
783
+ todo = self.todo_manager.update_todo(rest, status="cancelled")
784
+ if todo:
785
+ self.console.print(f"[red]❌ Cancelled: {todo.title}[/red]")
786
+ else:
787
+ self.console.print(f"[red]Todo not found: {rest}[/red]")
788
+ else:
789
+ self.console.print("[yellow]Usage: /todo cancel <id>[/yellow]")
790
+
791
+ elif subcommand in ["delete", "del", "rm", "remove"]:
792
+ # Delete todo
793
+ if rest:
794
+ if self.todo_manager.delete_todo(rest):
795
+ self.console.print(f"[green]✅ Deleted todo: {rest}[/green]")
796
+ else:
797
+ self.console.print(f"[red]Todo not found: {rest}[/red]")
798
+ else:
799
+ self.console.print("[yellow]Usage: /todo delete <id>[/yellow]")
800
+
801
+ elif subcommand in ["view", "show", "detail"]:
802
+ # View todo detail
803
+ if rest:
804
+ todo = self.todo_manager.get_todo(rest)
805
+ if todo:
806
+ self.todo_manager.display_todo_detail(todo)
807
+ else:
808
+ self.console.print(f"[red]Todo not found: {rest}[/red]")
809
+ else:
810
+ self.console.print("[yellow]Usage: /todo view <id>[/yellow]")
811
+
812
+ elif subcommand in ["stats", "statistics"]:
813
+ # Show statistics
814
+ self.todo_manager.display_statistics()
815
+
816
+ elif subcommand in ["clear", "reset"]:
817
+ # Clear all todos (with confirmation)
818
+ try:
819
+ confirm = await self.session.prompt_async("Are you sure you want to delete ALL todos? (yes/no): ")
820
+ if confirm.lower() in ["yes", "y"]:
821
+ self.todo_manager.todos = []
822
+ self.todo_manager.save_todos()
823
+ self.console.print("[green]✅ All todos cleared[/green]")
824
+ else:
825
+ self.console.print("[yellow]Cancelled[/yellow]")
826
+ except (KeyboardInterrupt, EOFError):
827
+ self.console.print("[yellow]Cancelled[/yellow]")
828
+
829
+ elif subcommand in ["help", "h", "?"]:
830
+ # Show todo help
831
+ self.show_todo_help()
832
+
833
+ else:
834
+ # Unknown subcommand, treat as quick add
835
+ try:
836
+ todo = self.todo_manager.quick_add(args)
837
+ self.console.print(f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]")
838
+ except ValueError:
839
+ self.console.print(f"[yellow]Unknown todo command: {subcommand}[/yellow]")
840
+ self.console.print("[dim]Use /todo help for available commands[/dim]")
841
+
842
+ async def add_todo_interactive(self):
843
+ """Add todo interactively."""
844
+ try:
845
+ # Get title
846
+ title = await self.session.prompt_async("Title: ")
847
+ if not title:
848
+ self.console.print("[yellow]Cancelled[/yellow]")
849
+ return
850
+
851
+ # Get description
852
+ description = await self.session.prompt_async("Description (optional): ")
853
+
854
+ # Get priority
855
+ priority = await self.session.prompt_async("Priority (low/medium/high/urgent) [medium]: ")
856
+ if not priority:
857
+ priority = "medium"
858
+
859
+ # Get tags
860
+ tags_input = await self.session.prompt_async("Tags (comma-separated, optional): ")
861
+ tags = [t.strip() for t in tags_input.split(",") if t.strip()] if tags_input else []
862
+
863
+ # Get due date
864
+ due_date = await self.session.prompt_async("Due date (optional): ")
865
+
866
+ # Add todo
867
+ todo = self.todo_manager.add_todo(
868
+ title=title,
869
+ description=description,
870
+ priority=priority,
871
+ tags=tags,
872
+ due_date=due_date if due_date else None
873
+ )
874
+
875
+ self.console.print(f"[green]✅ Added todo: {todo.title} (ID: {todo.id})[/green]")
876
+
877
+ except (KeyboardInterrupt, EOFError):
878
+ self.console.print("[yellow]Cancelled[/yellow]")
879
+
880
+ def show_todo_help(self):
881
+ """Show todo help."""
882
+ help_text = """
883
+ [bold cyan]Todo Management[/bold cyan]
884
+
885
+ [bold]Quick Add:[/bold]
886
+ /todo Buy milk #shopping !high @tomorrow
887
+ Format: title #tag1 #tag2 !priority @due_date
888
+
889
+ [bold]Commands:[/bold]
890
+ /todo - List all todos
891
+ /todo add <text> - Quick add todo
892
+ /todo list [filters] - List with filters
893
+ /todo done <id> - Mark as done
894
+ /todo start <id> - Mark as in progress
895
+ /todo cancel <id> - Cancel todo
896
+ /todo delete <id> - Delete todo
897
+ /todo view <id> - View todo details
898
+ /todo stats - Show statistics
899
+ /todo clear - Clear all todos
900
+ /todo help - Show this help
901
+
902
+ [bold]List Filters:[/bold]
903
+ /todo list status todo
904
+ /todo list priority high
905
+ /todo list tag work
906
+
907
+ [bold]Shortcuts:[/bold]
908
+ /todo a = add
909
+ /todo ls = list
910
+ /todo d = done
911
+ /todo rm = delete
912
+ """
913
+ self.console.print(help_text)
@@ -0,0 +1,457 @@
1
+ """Native todo management for Hanzo REPL."""
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import List, Dict, Optional, Any
8
+ from enum import Enum
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+ from rich import box
15
+ from rich.prompt import Prompt, Confirm
16
+
17
+
18
+ class TodoPriority(Enum):
19
+ """Todo priority levels."""
20
+ LOW = "low"
21
+ MEDIUM = "medium"
22
+ HIGH = "high"
23
+ URGENT = "urgent"
24
+
25
+
26
+ class TodoStatus(Enum):
27
+ """Todo status."""
28
+ TODO = "todo"
29
+ IN_PROGRESS = "in_progress"
30
+ DONE = "done"
31
+ CANCELLED = "cancelled"
32
+
33
+
34
+ class Todo:
35
+ """Single todo item."""
36
+
37
+ def __init__(
38
+ self,
39
+ title: str,
40
+ description: str = "",
41
+ priority: TodoPriority = TodoPriority.MEDIUM,
42
+ status: TodoStatus = TodoStatus.TODO,
43
+ tags: List[str] = None,
44
+ due_date: Optional[str] = None,
45
+ id: Optional[str] = None,
46
+ created_at: Optional[str] = None,
47
+ updated_at: Optional[str] = None,
48
+ completed_at: Optional[str] = None
49
+ ):
50
+ self.id = id or str(uuid.uuid4())[:8]
51
+ self.title = title
52
+ self.description = description
53
+ self.priority = priority
54
+ self.status = status
55
+ self.tags = tags or []
56
+ self.due_date = due_date
57
+ self.created_at = created_at or datetime.now().isoformat()
58
+ self.updated_at = updated_at or datetime.now().isoformat()
59
+ self.completed_at = completed_at
60
+
61
+ def to_dict(self) -> Dict[str, Any]:
62
+ """Convert to dictionary."""
63
+ return {
64
+ "id": self.id,
65
+ "title": self.title,
66
+ "description": self.description,
67
+ "priority": self.priority.value,
68
+ "status": self.status.value,
69
+ "tags": self.tags,
70
+ "due_date": self.due_date,
71
+ "created_at": self.created_at,
72
+ "updated_at": self.updated_at,
73
+ "completed_at": self.completed_at
74
+ }
75
+
76
+ @classmethod
77
+ def from_dict(cls, data: Dict[str, Any]) -> "Todo":
78
+ """Create from dictionary."""
79
+ return cls(
80
+ id=data.get("id"),
81
+ title=data["title"],
82
+ description=data.get("description", ""),
83
+ priority=TodoPriority(data.get("priority", "medium")),
84
+ status=TodoStatus(data.get("status", "todo")),
85
+ tags=data.get("tags", []),
86
+ due_date=data.get("due_date"),
87
+ created_at=data.get("created_at"),
88
+ updated_at=data.get("updated_at"),
89
+ completed_at=data.get("completed_at")
90
+ )
91
+
92
+
93
+ class TodoManager:
94
+ """Manage todos with persistent storage."""
95
+
96
+ def __init__(self, console: Optional[Console] = None):
97
+ self.console = console or Console()
98
+ self.config_dir = Path.home() / ".hanzo"
99
+ self.todos_file = self.config_dir / "todos.json"
100
+ self.todos: List[Todo] = []
101
+ self.load_todos()
102
+
103
+ def load_todos(self):
104
+ """Load todos from file."""
105
+ if self.todos_file.exists():
106
+ try:
107
+ data = json.loads(self.todos_file.read_text())
108
+ self.todos = [Todo.from_dict(t) for t in data.get("todos", [])]
109
+ except Exception as e:
110
+ self.console.print(f"[red]Error loading todos: {e}[/red]")
111
+ self.todos = []
112
+ else:
113
+ self.todos = []
114
+
115
+ def save_todos(self):
116
+ """Save todos to file."""
117
+ self.config_dir.mkdir(exist_ok=True)
118
+ data = {
119
+ "todos": [t.to_dict() for t in self.todos],
120
+ "last_updated": datetime.now().isoformat()
121
+ }
122
+ self.todos_file.write_text(json.dumps(data, indent=2))
123
+
124
+ def add_todo(
125
+ self,
126
+ title: str,
127
+ description: str = "",
128
+ priority: str = "medium",
129
+ tags: List[str] = None,
130
+ due_date: Optional[str] = None
131
+ ) -> Todo:
132
+ """Add a new todo."""
133
+ try:
134
+ priority_enum = TodoPriority(priority.lower())
135
+ except ValueError:
136
+ priority_enum = TodoPriority.MEDIUM
137
+
138
+ todo = Todo(
139
+ title=title,
140
+ description=description,
141
+ priority=priority_enum,
142
+ tags=tags or [],
143
+ due_date=due_date
144
+ )
145
+
146
+ self.todos.append(todo)
147
+ self.save_todos()
148
+
149
+ return todo
150
+
151
+ def update_todo(self, todo_id: str, **kwargs) -> Optional[Todo]:
152
+ """Update a todo."""
153
+ todo = self.get_todo(todo_id)
154
+ if not todo:
155
+ return None
156
+
157
+ # Update fields
158
+ if "title" in kwargs:
159
+ todo.title = kwargs["title"]
160
+ if "description" in kwargs:
161
+ todo.description = kwargs["description"]
162
+ if "priority" in kwargs:
163
+ try:
164
+ todo.priority = TodoPriority(kwargs["priority"].lower())
165
+ except ValueError:
166
+ pass
167
+ if "status" in kwargs:
168
+ try:
169
+ new_status = TodoStatus(kwargs["status"].lower())
170
+ todo.status = new_status
171
+
172
+ # Update completed timestamp
173
+ if new_status == TodoStatus.DONE:
174
+ todo.completed_at = datetime.now().isoformat()
175
+ elif todo.status == TodoStatus.DONE and new_status != TodoStatus.DONE:
176
+ todo.completed_at = None
177
+ except ValueError:
178
+ pass
179
+ if "tags" in kwargs:
180
+ todo.tags = kwargs["tags"]
181
+ if "due_date" in kwargs:
182
+ todo.due_date = kwargs["due_date"]
183
+
184
+ todo.updated_at = datetime.now().isoformat()
185
+ self.save_todos()
186
+
187
+ return todo
188
+
189
+ def delete_todo(self, todo_id: str) -> bool:
190
+ """Delete a todo."""
191
+ todo = self.get_todo(todo_id)
192
+ if todo:
193
+ self.todos.remove(todo)
194
+ self.save_todos()
195
+ return True
196
+ return False
197
+
198
+ def get_todo(self, todo_id: str) -> Optional[Todo]:
199
+ """Get a todo by ID."""
200
+ for todo in self.todos:
201
+ if todo.id == todo_id:
202
+ return todo
203
+ return None
204
+
205
+ def list_todos(
206
+ self,
207
+ status: Optional[str] = None,
208
+ priority: Optional[str] = None,
209
+ tag: Optional[str] = None
210
+ ) -> List[Todo]:
211
+ """List todos with optional filters."""
212
+ filtered = self.todos
213
+
214
+ # Filter by status
215
+ if status:
216
+ try:
217
+ status_enum = TodoStatus(status.lower())
218
+ filtered = [t for t in filtered if t.status == status_enum]
219
+ except ValueError:
220
+ pass
221
+
222
+ # Filter by priority
223
+ if priority:
224
+ try:
225
+ priority_enum = TodoPriority(priority.lower())
226
+ filtered = [t for t in filtered if t.priority == priority_enum]
227
+ except ValueError:
228
+ pass
229
+
230
+ # Filter by tag
231
+ if tag:
232
+ filtered = [t for t in filtered if tag in t.tags]
233
+
234
+ # Sort by priority and status
235
+ priority_order = {
236
+ TodoPriority.URGENT: 0,
237
+ TodoPriority.HIGH: 1,
238
+ TodoPriority.MEDIUM: 2,
239
+ TodoPriority.LOW: 3
240
+ }
241
+
242
+ status_order = {
243
+ TodoStatus.IN_PROGRESS: 0,
244
+ TodoStatus.TODO: 1,
245
+ TodoStatus.DONE: 2,
246
+ TodoStatus.CANCELLED: 3
247
+ }
248
+
249
+ filtered.sort(key=lambda t: (
250
+ status_order.get(t.status, 999),
251
+ priority_order.get(t.priority, 999),
252
+ t.created_at
253
+ ))
254
+
255
+ return filtered
256
+
257
+ def display_todos(
258
+ self,
259
+ todos: Optional[List[Todo]] = None,
260
+ title: str = "Todos"
261
+ ):
262
+ """Display todos in a nice table."""
263
+ if todos is None:
264
+ todos = self.list_todos()
265
+
266
+ if not todos:
267
+ self.console.print("[yellow]No todos found[/yellow]")
268
+ return
269
+
270
+ # Create table
271
+ table = Table(title=title, box=box.ROUNDED)
272
+ table.add_column("ID", style="cyan", width=8)
273
+ table.add_column("Status", width=12)
274
+ table.add_column("Priority", width=8)
275
+ table.add_column("Title", style="white")
276
+ table.add_column("Tags", style="dim")
277
+ table.add_column("Due", style="yellow")
278
+
279
+ for todo in todos:
280
+ # Status emoji
281
+ status_display = {
282
+ TodoStatus.TODO: "⭕ Todo",
283
+ TodoStatus.IN_PROGRESS: "🔄 In Progress",
284
+ TodoStatus.DONE: "✅ Done",
285
+ TodoStatus.CANCELLED: "❌ Cancelled"
286
+ }.get(todo.status, todo.status.value)
287
+
288
+ # Priority color
289
+ priority_color = {
290
+ TodoPriority.URGENT: "red bold",
291
+ TodoPriority.HIGH: "red",
292
+ TodoPriority.MEDIUM: "yellow",
293
+ TodoPriority.LOW: "green"
294
+ }.get(todo.priority, "white")
295
+
296
+ priority_display = f"[{priority_color}]{todo.priority.value.upper()}[/{priority_color}]"
297
+
298
+ # Tags
299
+ tags_display = ", ".join(todo.tags) if todo.tags else "-"
300
+
301
+ # Due date
302
+ due_display = todo.due_date if todo.due_date else "-"
303
+
304
+ table.add_row(
305
+ todo.id,
306
+ status_display,
307
+ priority_display,
308
+ todo.title,
309
+ tags_display,
310
+ due_display
311
+ )
312
+
313
+ self.console.print(table)
314
+
315
+ # Summary
316
+ total = len(todos)
317
+ done = len([t for t in todos if t.status == TodoStatus.DONE])
318
+ in_progress = len([t for t in todos if t.status == TodoStatus.IN_PROGRESS])
319
+ todo_count = len([t for t in todos if t.status == TodoStatus.TODO])
320
+
321
+ summary = f"Total: {total} | Todo: {todo_count} | In Progress: {in_progress} | Done: {done}"
322
+ self.console.print(f"\n[dim]{summary}[/dim]")
323
+
324
+ def display_todo_detail(self, todo: Todo):
325
+ """Display detailed view of a todo."""
326
+ # Status color
327
+ status_color = {
328
+ TodoStatus.TODO: "yellow",
329
+ TodoStatus.IN_PROGRESS: "cyan",
330
+ TodoStatus.DONE: "green",
331
+ TodoStatus.CANCELLED: "red"
332
+ }.get(todo.status, "white")
333
+
334
+ # Priority color
335
+ priority_color = {
336
+ TodoPriority.URGENT: "red bold",
337
+ TodoPriority.HIGH: "red",
338
+ TodoPriority.MEDIUM: "yellow",
339
+ TodoPriority.LOW: "green"
340
+ }.get(todo.priority, "white")
341
+
342
+ # Build content
343
+ content = f"""
344
+ [bold]{todo.title}[/bold]
345
+
346
+ [dim]ID:[/dim] {todo.id}
347
+ [dim]Status:[/dim] [{status_color}]{todo.status.value.replace('_', ' ').title()}[/{status_color}]
348
+ [dim]Priority:[/dim] [{priority_color}]{todo.priority.value.upper()}[/{priority_color}]
349
+ [dim]Tags:[/dim] {', '.join(todo.tags) if todo.tags else 'None'}
350
+ [dim]Due Date:[/dim] {todo.due_date if todo.due_date else 'Not set'}
351
+
352
+ [dim]Description:[/dim]
353
+ {todo.description if todo.description else 'No description'}
354
+
355
+ [dim]Created:[/dim] {todo.created_at}
356
+ [dim]Updated:[/dim] {todo.updated_at}
357
+ [dim]Completed:[/dim] {todo.completed_at if todo.completed_at else 'Not completed'}
358
+ """
359
+
360
+ panel = Panel(content.strip(), title=f"Todo: {todo.title}", box=box.ROUNDED)
361
+ self.console.print(panel)
362
+
363
+ def quick_add(self, text: str) -> Todo:
364
+ """Quick add todo from text.
365
+
366
+ Format: title #tag1 #tag2 !priority @due_date
367
+ """
368
+ import re
369
+
370
+ # Extract tags (words starting with #)
371
+ tags = re.findall(r'#(\w+)', text)
372
+ text = re.sub(r'#\w+', '', text)
373
+
374
+ # Extract priority (word after !)
375
+ priority_match = re.search(r'!(\w+)', text)
376
+ priority = priority_match.group(1) if priority_match else "medium"
377
+ text = re.sub(r'!\w+', '', text)
378
+
379
+ # Extract due date (text after @)
380
+ due_match = re.search(r'@([^\s]+)', text)
381
+ due_date = due_match.group(1) if due_match else None
382
+ text = re.sub(r'@[^\s]+', '', text)
383
+
384
+ # Clean up title
385
+ title = text.strip()
386
+
387
+ if not title:
388
+ raise ValueError("Todo title cannot be empty")
389
+
390
+ return self.add_todo(
391
+ title=title,
392
+ priority=priority,
393
+ tags=tags,
394
+ due_date=due_date
395
+ )
396
+
397
+ def get_statistics(self) -> Dict[str, Any]:
398
+ """Get todo statistics."""
399
+ total = len(self.todos)
400
+
401
+ # Status counts
402
+ status_counts = {}
403
+ for status in TodoStatus:
404
+ count = len([t for t in self.todos if t.status == status])
405
+ status_counts[status.value] = count
406
+
407
+ # Priority counts
408
+ priority_counts = {}
409
+ for priority in TodoPriority:
410
+ count = len([t for t in self.todos if t.priority == priority])
411
+ priority_counts[priority.value] = count
412
+
413
+ # Tags
414
+ all_tags = set()
415
+ for todo in self.todos:
416
+ all_tags.update(todo.tags)
417
+
418
+ # Completion rate
419
+ done = status_counts.get("done", 0)
420
+ completion_rate = (done / total * 100) if total > 0 else 0
421
+
422
+ return {
423
+ "total": total,
424
+ "status": status_counts,
425
+ "priority": priority_counts,
426
+ "tags": list(all_tags),
427
+ "completion_rate": completion_rate
428
+ }
429
+
430
+ def display_statistics(self):
431
+ """Display todo statistics."""
432
+ stats = self.get_statistics()
433
+
434
+ # Create stats panel
435
+ content = f"""
436
+ [bold cyan]Todo Statistics[/bold cyan]
437
+
438
+ [bold]Total Todos:[/bold] {stats['total']}
439
+ [bold]Completion Rate:[/bold] {stats['completion_rate']:.1f}%
440
+
441
+ [bold]By Status:[/bold]
442
+ ⭕ Todo: {stats['status'].get('todo', 0)}
443
+ 🔄 In Progress: {stats['status'].get('in_progress', 0)}
444
+ ✅ Done: {stats['status'].get('done', 0)}
445
+ ❌ Cancelled: {stats['status'].get('cancelled', 0)}
446
+
447
+ [bold]By Priority:[/bold]
448
+ 🔴 Urgent: {stats['priority'].get('urgent', 0)}
449
+ 🟠 High: {stats['priority'].get('high', 0)}
450
+ 🟡 Medium: {stats['priority'].get('medium', 0)}
451
+ 🟢 Low: {stats['priority'].get('low', 0)}
452
+
453
+ [bold]Tags:[/bold] {', '.join(stats['tags']) if stats['tags'] else 'None'}
454
+ """
455
+
456
+ panel = Panel(content.strip(), title="📊 Statistics", box=box.ROUNDED)
457
+ self.console.print(panel)
hanzo/tools/detector.py CHANGED
@@ -3,6 +3,7 @@
3
3
  import os
4
4
  import shutil
5
5
  import subprocess
6
+ import httpx
6
7
  from pathlib import Path
7
8
  from typing import Dict, List, Optional, Tuple
8
9
  from dataclasses import dataclass
@@ -33,12 +34,33 @@ class ToolDetector:
33
34
 
34
35
  # Define available tools with priority order
35
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
+ ),
36
58
  AITool(
37
59
  name="claude-code",
38
60
  command="claude",
39
61
  display_name="Claude Code",
40
62
  provider="anthropic",
41
- priority=1,
63
+ priority=2,
42
64
  check_command="claude --version",
43
65
  env_var="ANTHROPIC_API_KEY"
44
66
  ),
@@ -47,7 +69,7 @@ class ToolDetector:
47
69
  command="hanzo dev",
48
70
  display_name="Hanzo Dev (Native)",
49
71
  provider="hanzo",
50
- priority=2,
72
+ priority=3,
51
73
  check_command="hanzo --version",
52
74
  env_var="HANZO_API_KEY"
53
75
  ),
@@ -56,7 +78,7 @@ class ToolDetector:
56
78
  command="openai",
57
79
  display_name="OpenAI Codex",
58
80
  provider="openai",
59
- priority=3,
81
+ priority=4,
60
82
  check_command="openai --version",
61
83
  env_var="OPENAI_API_KEY"
62
84
  ),
@@ -65,7 +87,7 @@ class ToolDetector:
65
87
  command="gemini",
66
88
  display_name="Gemini CLI",
67
89
  provider="google",
68
- priority=4,
90
+ priority=5,
69
91
  check_command="gemini --version",
70
92
  env_var="GEMINI_API_KEY"
71
93
  ),
@@ -74,7 +96,7 @@ class ToolDetector:
74
96
  command="grok",
75
97
  display_name="Grok CLI",
76
98
  provider="xai",
77
- priority=5,
99
+ priority=6,
78
100
  check_command="grok --version",
79
101
  env_var="GROK_API_KEY"
80
102
  ),
@@ -83,7 +105,7 @@ class ToolDetector:
83
105
  command="openhands",
84
106
  display_name="OpenHands CLI",
85
107
  provider="openhands",
86
- priority=6,
108
+ priority=7,
87
109
  check_command="openhands --version",
88
110
  env_var=None
89
111
  ),
@@ -92,7 +114,7 @@ class ToolDetector:
92
114
  command="cursor",
93
115
  display_name="Cursor AI",
94
116
  provider="cursor",
95
- priority=7,
117
+ priority=8,
96
118
  check_command="cursor --version",
97
119
  env_var=None
98
120
  ),
@@ -101,7 +123,7 @@ class ToolDetector:
101
123
  command="codeium",
102
124
  display_name="Codeium",
103
125
  provider="codeium",
104
- priority=8,
126
+ priority=9,
105
127
  check_command="codeium --version",
106
128
  env_var="CODEIUM_API_KEY"
107
129
  ),
@@ -110,7 +132,7 @@ class ToolDetector:
110
132
  command="aider",
111
133
  display_name="Aider",
112
134
  provider="aider",
113
- priority=9,
135
+ priority=10,
114
136
  check_command="aider --version",
115
137
  env_var=None
116
138
  ),
@@ -119,7 +141,7 @@ class ToolDetector:
119
141
  command="continue",
120
142
  display_name="Continue Dev",
121
143
  provider="continue",
122
- priority=10,
144
+ priority=11,
123
145
  check_command="continue --version",
124
146
  env_var=None
125
147
  )
@@ -143,26 +165,51 @@ class ToolDetector:
143
165
 
144
166
  def detect_tool(self, tool: AITool) -> bool:
145
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
+
146
192
  # Check if command exists
147
- tool.path = shutil.which(tool.command.split()[0])
148
- if tool.path:
149
- tool.detected = True
150
-
151
- # Try to get version
152
- if tool.check_command:
153
- try:
154
- result = subprocess.run(
155
- tool.check_command.split(),
156
- capture_output=True,
157
- text=True,
158
- timeout=2
159
- )
160
- if result.returncode == 0:
161
- tool.version = result.stdout.strip().split()[-1]
162
- except:
163
- pass
164
-
165
- return True
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
166
213
 
167
214
  # Check environment variable as fallback
168
215
  if tool.env_var and os.getenv(tool.env_var):
@@ -229,13 +276,25 @@ class ToolDetector:
229
276
  if self.detected_tools:
230
277
  default = self.detected_tools[0]
231
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]")
232
284
  else:
233
285
  self.console.print("\n[yellow]No AI coding tools detected.[/yellow]")
234
- self.console.print("[dim]Install Claude Code, OpenAI CLI, or other tools to enable AI features.[/dim]")
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]")
235
288
 
236
289
  def get_tool_command(self, tool: AITool, prompt: str) -> List[str]:
237
290
  """Get the command to execute for a tool with a prompt."""
238
- if tool.name == "claude-code":
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":
239
298
  return ["claude", prompt]
240
299
  elif tool.name == "hanzo-dev":
241
300
  return ["hanzo", "dev", "--prompt", prompt]
@@ -257,6 +316,43 @@ class ToolDetector:
257
316
  def execute_with_tool(self, tool: AITool, prompt: str) -> Tuple[bool, str]:
258
317
  """Execute a prompt with a specific tool."""
259
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
260
356
  command = self.get_tool_command(tool, prompt)
261
357
  result = subprocess.run(
262
358
  command,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hanzo
3
- Version: 0.3.26
3
+ Version: 0.3.28
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=LfN4DPjEcez05Rm3sPHjkvkZyjKX9G3flmwmBPSaXrc,185
1
+ hanzo/__init__.py,sha256=g4XELSN8aLffy0YoVIjALuubohGyKAN9lejacSqkEOs,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,12 +27,13 @@ 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=dmYun_rtWswoxVIVGamKASMJVS7QK5-TkuS_lIVzb4w,25493
30
+ hanzo/interactive/enhanced_repl.py,sha256=G_r1MEg3F0Hq9PQiDuJfiOYA2KT5A6_f-xerJWvTSo0,34235
31
31
  hanzo/interactive/model_selector.py,sha256=4HcXvr8AI8Y5IttMH7Dhb8M0vqzP5y3S5kQrQmopYuw,5519
32
32
  hanzo/interactive/repl.py,sha256=PXpRw1Cfqdqy1pQsKLqz9AwKJBFZ_Y758MpDlJIb9ao,6938
33
+ hanzo/interactive/todo_manager.py,sha256=tKsmfN4snILLkgwnjQcStP4CxYfNCXS5_pvQfmxkIjw,14640
33
34
  hanzo/router/__init__.py,sha256=_cRG9nHC_wwq17iVYZSUNBYiJDdByfLDVEuIQn5-ePM,978
34
35
  hanzo/tools/__init__.py,sha256=SsgmDvw5rO--NF4vKL9tV3O4WCNEl9aAIuqyTGSZ4RQ,122
35
- hanzo/tools/detector.py,sha256=xCpIkva4GZ2s92j2I63B7Q-rSohaHrXfeVOSOeiDL-U,9756
36
+ hanzo/tools/detector.py,sha256=qwVc1fIDt2lDuqFqjhTVCnToRka91n125mpOpsPCfTU,14054
36
37
  hanzo/ui/__init__.py,sha256=Ea22ereOm5Y0DDfyonA6qsO9Qkzofzd1CUE-VGW2lqw,241
37
38
  hanzo/ui/inline_startup.py,sha256=7Y5dwqzt-L1J0F9peyqJ8XZgjHSua2nkItDTrLlBnhU,4265
38
39
  hanzo/ui/startup.py,sha256=s7gP1QleQEIoCS1K0XBY7d6aufnwhicRLZDL7ej8ZZY,12235
@@ -40,7 +41,7 @@ hanzo/utils/__init__.py,sha256=5RRwKI852vp8smr4xCRgeKfn7dLEnHbdXGfVYTZ5jDQ,69
40
41
  hanzo/utils/config.py,sha256=FD_LoBpcoF5dgJ7WL4o6LDp2pdOy8kS-dJ6iRO2GcGM,4728
41
42
  hanzo/utils/net_check.py,sha256=YFbJ65SzfDYHkHLZe3n51VhId1VI3zhyx8p6BM-l6jE,3017
42
43
  hanzo/utils/output.py,sha256=W0j3psF07vJiX4s02gbN4zYWfbKNsb8TSIoagBSf5vA,2704
43
- hanzo-0.3.26.dist-info/METADATA,sha256=uZcjicpcs7ZJMa4YqgpNYifLYPA5CqkEKpzZEPRVrzg,6061
44
- hanzo-0.3.26.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
45
- hanzo-0.3.26.dist-info/entry_points.txt,sha256=pQLPMdqOXU_2BfTcMDhkqTCDNk_H6ApvYuSaWcuQOOw,171
46
- hanzo-0.3.26.dist-info/RECORD,,
44
+ hanzo-0.3.28.dist-info/METADATA,sha256=19BVN0yCDTn0FUruIXi1Mnh3VyqvlINVgySZ_HfYJ4o,6061
45
+ hanzo-0.3.28.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
46
+ hanzo-0.3.28.dist-info/entry_points.txt,sha256=pQLPMdqOXU_2BfTcMDhkqTCDNk_H6ApvYuSaWcuQOOw,171
47
+ hanzo-0.3.28.dist-info/RECORD,,
File without changes