hanuscode 1.0.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.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. hanuscode-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,754 @@
1
+ # plugin_manager.py
2
+ """Dynamic plugin loading from /plugins folder with enable/disable support and hot-reload."""
3
+ from __future__ import annotations
4
+ import importlib.util
5
+ import traceback
6
+ import json
7
+ import threading
8
+ import time
9
+ import hashlib
10
+ import requests
11
+ from pathlib import Path
12
+ from typing import Dict, Optional, Any, Set, Callable, List
13
+ from dataclasses import dataclass
14
+ from datetime import datetime
15
+
16
+
17
+ # Official plugins repository
18
+ PLUGINS_REPO_URL = "https://raw.githubusercontent.com/hanuscode/hanuscode-plugins/main/plugins"
19
+ PLUGINS_REPO_PAGE = "https://github.com/hanuscode/hanuscode-plugins/tree/main/plugins"
20
+
21
+
22
+ @dataclass
23
+ class PluginFileState:
24
+ """Estado de un archivo de plugin para detectar cambios."""
25
+ path: Path
26
+ mtime: float
27
+ size: int
28
+ hash: str
29
+
30
+
31
+ class Plugin:
32
+ def __init__(self, module: Any, path: Path):
33
+ self.module = module
34
+ self.path = path
35
+ self.name = getattr(module, "NAME", path.stem)
36
+ self.description = getattr(module, "DESCRIPTION", "No description")
37
+ self.usage = getattr(module, "USAGE", "")
38
+ self.enabled = True # Default to enabled
39
+ self.loaded_at = time.time()
40
+ self.file_hash = self._compute_file_hash()
41
+
42
+ def _compute_file_hash(self) -> str:
43
+ """Computa hash del archivo fuente para detectar cambios."""
44
+ try:
45
+ content = self.path.read_bytes()
46
+ return hashlib.md5(content).hexdigest()
47
+ except Exception:
48
+ return ""
49
+
50
+ def run(self, args: str = "") -> str:
51
+ if not self.enabled:
52
+ return f"[Plugin {self.name}] Plugin is disabled. Use '/plugins enable {self.name}' to enable."
53
+ try:
54
+ fn = getattr(self.module, "run", None)
55
+ return str(fn(args)) if callable(fn) else f"[Plugin {self.name}] No run() function"
56
+ except Exception as e:
57
+ return f"[Plugin {self.name}] Error: {e}\n{traceback.format_exc()}"
58
+
59
+ def get_doc(self) -> str:
60
+ status = "✓" if self.enabled else "✗"
61
+ lines = [
62
+ f"### Plugin: {self.name} [{status}]",
63
+ f"**Description:** {self.description}",
64
+ f"**Usage:** `<run_plugin name=\"{self.name}\" args=\"{self.usage}\"/>`",
65
+ ]
66
+ extra = getattr(self.module, "AGENT_DOC", "")
67
+ if extra:
68
+ lines.append(extra)
69
+ return "\n".join(lines)
70
+
71
+ def needs_reload(self) -> bool:
72
+ """Verifica si el archivo del plugin ha cambiado."""
73
+ try:
74
+ if not self.path.exists():
75
+ return False
76
+ current_hash = self._compute_file_hash()
77
+ return current_hash != self.file_hash
78
+ except Exception:
79
+ return False
80
+
81
+
82
+ class PluginManager:
83
+ CONFIG_FILE = Path.home() / ".hanus" / "plugins_config.json"
84
+
85
+ def __init__(self, plugins_dir: Path, enable_hot_reload: bool = True):
86
+ self.plugins_dir = plugins_dir
87
+ self.plugins: Dict[str, Plugin] = {}
88
+ self.disabled_plugins: Set[str] = set() # Set of disabled plugin names
89
+
90
+ # Hot-reload support - inicializar ANTES de cargar plugins
91
+ self._enable_hot_reload = enable_hot_reload
92
+ self._file_states: Dict[str, PluginFileState] = {}
93
+ self._watcher_thread: Optional[threading.Thread] = None
94
+ self._watcher_running = False
95
+ self._reload_callbacks: List[Callable[[str, str], None]] = [] # (event, plugin_name)
96
+
97
+ # Callbacks para notificar a WebUI
98
+ self._ui_notify_callback: Optional[Callable[[str, Any], None]] = None
99
+
100
+ # Cargar configuración y plugins
101
+ self._load_config()
102
+ self._load_all()
103
+
104
+ # Iniciar watcher si está habilitado
105
+ if enable_hot_reload:
106
+ self.start_hot_reload_watcher()
107
+
108
+ def _init_file_states(self) -> None:
109
+ """Inicializa los estados de los archivos de plugins."""
110
+ for name, plugin in self.plugins.items():
111
+ try:
112
+ stat = plugin.path.stat()
113
+ self._file_states[name] = PluginFileState(
114
+ path=plugin.path,
115
+ mtime=stat.st_mtime,
116
+ size=stat.st_size,
117
+ hash=plugin.file_hash
118
+ )
119
+ except Exception:
120
+ pass
121
+
122
+ # ══════════════════════════════════════════════════════════════════════════
123
+ # HOT-RELOAD API
124
+ # ══════════════════════════════════════════════════════════════════════════
125
+
126
+ def start_hot_reload_watcher(self, interval: float = 2.0) -> None:
127
+ """
128
+ Inicia el watcher para detectar cambios en plugins.
129
+
130
+ Args:
131
+ interval: Intervalo en segundos entre chequeos
132
+ """
133
+ if self._watcher_running:
134
+ return
135
+
136
+ self._watcher_running = True
137
+ self._watcher_thread = threading.Thread(
138
+ target=self._watcher_loop,
139
+ args=(interval,),
140
+ daemon=True
141
+ )
142
+ self._watcher_thread.start()
143
+
144
+ def stop_hot_reload_watcher(self) -> None:
145
+ """Detiene el watcher de hot-reload."""
146
+ self._watcher_running = False
147
+ if self._watcher_thread:
148
+ self._watcher_thread.join(timeout=5.0)
149
+ self._watcher_thread = None
150
+
151
+ def reload_plugin(self, name: str) -> bool:
152
+ """
153
+ Recarga un plugin específico.
154
+
155
+ Args:
156
+ name: Nombre del plugin a recargar
157
+
158
+ Returns:
159
+ True si se recargó correctamente
160
+ """
161
+ if name not in self.plugins:
162
+ return False
163
+
164
+ plugin = self.plugins[name]
165
+ was_enabled = plugin.enabled
166
+
167
+ try:
168
+ # Recargar el módulo
169
+ spec = importlib.util.spec_from_file_location(
170
+ f"hanus_plugin_{plugin.path.stem}",
171
+ plugin.path
172
+ )
173
+ module = importlib.util.module_from_spec(spec)
174
+ spec.loader.exec_module(module)
175
+
176
+ # Crear nuevo plugin
177
+ new_plugin = Plugin(module, plugin.path)
178
+ new_plugin.enabled = was_enabled
179
+
180
+ # Actualizar
181
+ self.plugins[name] = new_plugin
182
+
183
+ # Actualizar estado de archivo
184
+ stat = plugin.path.stat()
185
+ self._file_states[name] = PluginFileState(
186
+ path=plugin.path,
187
+ mtime=stat.st_mtime,
188
+ size=stat.st_size,
189
+ hash=new_plugin.file_hash
190
+ )
191
+
192
+ # Notificar callbacks
193
+ self._notify_reload("reloaded", name)
194
+
195
+ return True
196
+
197
+ except Exception as e:
198
+ print(f"[PluginManager] Error reloading '{name}': {e}")
199
+ self._notify_reload("reload_error", f"{name}: {e}")
200
+ return False
201
+
202
+ def add_reload_callback(self, callback: Callable[[str, str], None]) -> None:
203
+ """
204
+ Añade un callback para eventos de reload.
205
+
206
+ Args:
207
+ callback: Función que recibe (event_type, plugin_name)
208
+ """
209
+ self._reload_callbacks.append(callback)
210
+
211
+ def set_ui_notify_callback(self, callback: Callable[[str, Any], None]) -> None:
212
+ """
213
+ Establece un callback para notificar a la UI.
214
+
215
+ Args:
216
+ callback: Función que recibe (event_type, data)
217
+ """
218
+ self._ui_notify_callback = callback
219
+
220
+ def get_hot_reload_status(self) -> Dict[str, Any]:
221
+ """Retorna el estado del sistema de hot-reload."""
222
+ return {
223
+ "enabled": self._enable_hot_reload,
224
+ "running": self._watcher_running,
225
+ "plugins_watched": len(self._file_states),
226
+ "last_check": datetime.now().isoformat() if self._watcher_running else None,
227
+ }
228
+
229
+ # ══════════════════════════════════════════════════════════════════════════
230
+ # PLUGIN INSTALLATION FROM REPOSITORY
231
+ # ══════════════════════════════════════════════════════════════════════════
232
+
233
+ def install_plugin(self, name: str, force: bool = False) -> Dict[str, Any]:
234
+ """
235
+ Install a plugin from the official repository.
236
+
237
+ Args:
238
+ name: Plugin name (without .py extension)
239
+ force: Overwrite if already exists
240
+
241
+ Returns:
242
+ Dict with 'success', 'message', and optionally 'error' keys
243
+ """
244
+ # Sanitize plugin name
245
+ safe_name = "".join(c for c in name.lower() if c.isalnum() or c in "_-")
246
+ if not safe_name:
247
+ return {"success": False, "error": "Invalid plugin name"}
248
+
249
+ # Check if already installed
250
+ if safe_name in self.plugins and not force:
251
+ return {
252
+ "success": False,
253
+ "error": f"Plugin '{safe_name}' is already installed. Use --force to overwrite."
254
+ }
255
+
256
+ # Build URL
257
+ url = f"{PLUGINS_REPO_URL}/{safe_name}.py"
258
+
259
+ try:
260
+ # Download plugin
261
+ response = requests.get(url, timeout=30)
262
+ response.raise_for_status()
263
+
264
+ # Validate content is Python code
265
+ content = response.text
266
+ if not content.strip():
267
+ return {"success": False, "error": "Downloaded file is empty"}
268
+
269
+ # Basic validation - check for required attributes
270
+ if "NAME =" not in content and "NAME=" not in content:
271
+ return {
272
+ "success": False,
273
+ "error": "Invalid plugin format: missing NAME attribute"
274
+ }
275
+
276
+ # Save to plugins directory
277
+ plugin_path = self.plugins_dir / f"{safe_name}.py"
278
+ plugin_path.write_text(content, encoding="utf-8")
279
+
280
+ # Load the plugin
281
+ self._load(plugin_path)
282
+
283
+ if safe_name in self.plugins:
284
+ return {
285
+ "success": True,
286
+ "message": f"✓ Plugin '{safe_name}' installed successfully!\n\n{self.plugins[safe_name].get_doc()}"
287
+ }
288
+ else:
289
+ return {
290
+ "success": False,
291
+ "error": f"Plugin downloaded but failed to load. Check for syntax errors."
292
+ }
293
+
294
+ except requests.exceptions.HTTPError as e:
295
+ if e.response.status_code == 404:
296
+ return {
297
+ "success": False,
298
+ "error": f"Plugin '{safe_name}' not found in repository.\n\nBrowse plugins at: {PLUGINS_REPO_PAGE}"
299
+ }
300
+ return {"success": False, "error": f"HTTP error: {e}"}
301
+
302
+ except requests.exceptions.RequestException as e:
303
+ return {"success": False, "error": f"Network error: {e}"}
304
+
305
+ except Exception as e:
306
+ return {"success": False, "error": f"Error installing plugin: {e}"}
307
+
308
+ def uninstall_plugin(self, name: str) -> Dict[str, Any]:
309
+ """
310
+ Uninstall a plugin by removing its file.
311
+
312
+ Args:
313
+ name: Plugin name to uninstall
314
+
315
+ Returns:
316
+ Dict with 'success' and 'message' keys
317
+ """
318
+ if name not in self.plugins:
319
+ return {"success": False, "error": f"Plugin '{name}' is not installed"}
320
+
321
+ plugin = self.plugins[name]
322
+
323
+ # Don't allow uninstalling builtin plugins
324
+ if "hanus/plugins" in str(plugin.path) and "site-packages" not in str(plugin.path):
325
+ # Check if it's in the package plugins directory
326
+ try:
327
+ import hanus
328
+ pkg_dir = Path(hanus.__file__).parent / "plugins"
329
+ if plugin.path.parent == pkg_dir:
330
+ return {
331
+ "success": False,
332
+ "error": f"Cannot uninstall builtin plugin '{name}'. Use 'disable' instead."
333
+ }
334
+ except ImportError:
335
+ pass
336
+
337
+ try:
338
+ # Remove from plugins dict
339
+ del self.plugins[name]
340
+
341
+ # Remove from disabled set if present
342
+ self.disabled_plugins.discard(name)
343
+
344
+ # Remove file
345
+ if plugin.path.exists():
346
+ plugin.path.unlink()
347
+
348
+ # Remove from file states
349
+ if name in self._file_states:
350
+ del self._file_states[name]
351
+
352
+ # Save config
353
+ self._save_config()
354
+
355
+ return {"success": True, "message": f"✓ Plugin '{name}' uninstalled successfully"}
356
+
357
+ except Exception as e:
358
+ return {"success": False, "error": f"Error uninstalling plugin: {e}"}
359
+
360
+ def list_available_plugins(self) -> Dict[str, Any]:
361
+ """
362
+ List plugins available in the repository.
363
+
364
+ Returns:
365
+ Dict with 'success', 'plugins' list, and optionally 'error'
366
+ """
367
+ try:
368
+ # Try to fetch the directory listing from GitHub API
369
+ api_url = "https://api.github.com/repos/hanuscode/hanuscode-plugins/contents/plugins"
370
+ response = requests.get(api_url, timeout=30)
371
+
372
+ if response.status_code == 200:
373
+ files = response.json()
374
+ plugins = []
375
+ for f in files:
376
+ if f["name"].endswith(".py") and not f["name"].startswith("_"):
377
+ plugins.append({
378
+ "name": f["name"][:-3], # Remove .py
379
+ "url": f["html_url"],
380
+ "size": f.get("size", 0),
381
+ })
382
+ return {"success": True, "plugins": plugins}
383
+ else:
384
+ # Fallback: return basic info
385
+ return {
386
+ "success": True,
387
+ "plugins": [],
388
+ "message": f"Could not fetch plugin list. Browse at: {PLUGINS_REPO_PAGE}"
389
+ }
390
+
391
+ except Exception as e:
392
+ return {
393
+ "success": True,
394
+ "plugins": [],
395
+ "message": f"Could not fetch plugin list: {e}\n\nBrowse at: {PLUGINS_REPO_PAGE}"
396
+ }
397
+
398
+ def update_plugin(self, name: str) -> Dict[str, Any]:
399
+ """
400
+ Update a plugin to the latest version from repository.
401
+
402
+ Args:
403
+ name: Plugin name to update
404
+
405
+ Returns:
406
+ Dict with 'success' and 'message' keys
407
+ """
408
+ if name not in self.plugins:
409
+ return {"success": False, "error": f"Plugin '{name}' is not installed"}
410
+
411
+ return self.install_plugin(name, force=True)
412
+
413
+ # ══════════════════════════════════════════════════════════════════════════
414
+ # MÉTODOS PRIVADOS HOT-RELOAD
415
+ # ══════════════════════════════════════════════════════════════════════════
416
+
417
+ def _watcher_loop(self, interval: float) -> None:
418
+ """Loop principal del watcher de archivos."""
419
+ while self._watcher_running:
420
+ try:
421
+ self._check_for_changes()
422
+ self._check_for_new_plugins()
423
+ except Exception as e:
424
+ print(f"[PluginManager] Watcher error: {e}")
425
+
426
+ time.sleep(interval)
427
+
428
+ def _check_for_changes(self) -> None:
429
+ """Verifica si algún plugin ha cambiado."""
430
+ for name, plugin in list(self.plugins.items()):
431
+ try:
432
+ if not plugin.path.exists():
433
+ continue
434
+
435
+ stat = plugin.path.stat()
436
+ old_state = self._file_states.get(name)
437
+
438
+ if old_state is None:
439
+ continue
440
+
441
+ # Verificar cambios por mtime y tamaño
442
+ if (stat.st_mtime != old_state.mtime or
443
+ stat.st_size != old_state.size):
444
+
445
+ # Verificar hash para confirmar cambio real
446
+ current_hash = plugin._compute_file_hash()
447
+ if current_hash != old_state.hash:
448
+ print(f"[PluginManager] Detected change in '{name}', reloading...")
449
+ self.reload_plugin(name)
450
+
451
+ except Exception:
452
+ pass
453
+
454
+ def _check_for_new_plugins(self) -> None:
455
+ """Verifica si hay nuevos plugins en el directorio."""
456
+ if not self.plugins_dir.exists():
457
+ return
458
+
459
+ for py in self.plugins_dir.glob("*.py"):
460
+ if py.name.startswith("_"):
461
+ continue
462
+
463
+ name = py.stem
464
+ if name not in self.plugins:
465
+ print(f"[PluginManager] New plugin detected: '{name}'")
466
+ self._load(py)
467
+ self._notify_reload("added", name)
468
+
469
+ def _notify_reload(self, event: str, plugin_name: str) -> None:
470
+ """Notifica a los callbacks sobre un evento de reload."""
471
+ # Callbacks locales
472
+ for callback in self._reload_callbacks:
473
+ try:
474
+ callback(event, plugin_name)
475
+ except Exception:
476
+ pass
477
+
478
+ # Callback de UI
479
+ if self._ui_notify_callback:
480
+ try:
481
+ self._ui_notify_callback("plugin_reload", {
482
+ "event": event,
483
+ "plugin": plugin_name,
484
+ "timestamp": datetime.now().isoformat()
485
+ })
486
+ except Exception:
487
+ pass
488
+
489
+ # ══════════════════════════════════════════════════════════════════════════
490
+ # MÉTODOS ORIGINALES
491
+ # ══════════════════════════════════════════════════════════════════════════
492
+
493
+ def _load_config(self):
494
+ """Load plugin configuration from file."""
495
+ if self.CONFIG_FILE.exists():
496
+ try:
497
+ config = json.loads(self.CONFIG_FILE.read_text(encoding="utf-8"))
498
+ self.disabled_plugins = set(config.get("disabled", []))
499
+ except Exception as e:
500
+ print(f" [PluginManager] Error loading config: {e}")
501
+ self.disabled_plugins = set()
502
+
503
+ def _save_config(self):
504
+ """Save plugin configuration to file."""
505
+ self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
506
+ config = {
507
+ "disabled": list(self.disabled_plugins)
508
+ }
509
+ self.CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
510
+
511
+ def _load_all(self):
512
+ """Load all plugins from directory."""
513
+ self.plugins.clear()
514
+ if not self.plugins_dir.exists():
515
+ return
516
+ for py in sorted(self.plugins_dir.glob("*.py")):
517
+ if not py.name.startswith("_"):
518
+ self._load(py)
519
+
520
+ def _load(self, path: Path):
521
+ """Load a single plugin."""
522
+ try:
523
+ spec = importlib.util.spec_from_file_location(f"hanus_plugin_{path.stem}", path)
524
+ module = importlib.util.module_from_spec(spec)
525
+ spec.loader.exec_module(module)
526
+ p = Plugin(module, path)
527
+
528
+ # Apply saved state
529
+ p.enabled = p.name not in self.disabled_plugins
530
+
531
+ self.plugins[p.name] = p
532
+
533
+ # Guardar estado de archivo para hot-reload
534
+ stat = path.stat()
535
+ self._file_states[p.name] = PluginFileState(
536
+ path=path,
537
+ mtime=stat.st_mtime,
538
+ size=stat.st_size,
539
+ hash=p.file_hash
540
+ )
541
+
542
+ except Exception as e:
543
+ print(f" [PluginManager] Error loading '{path.name}': {e}")
544
+
545
+ def reload(self):
546
+ """Reload all plugins."""
547
+ self._load_config()
548
+ self._load_all()
549
+
550
+ def enable(self, name: str) -> bool:
551
+ """Enable a plugin."""
552
+ if name not in self.plugins:
553
+ return False
554
+ self.plugins[name].enabled = True
555
+ self.disabled_plugins.discard(name)
556
+ self._save_config()
557
+ return True
558
+
559
+ def disable(self, name: str) -> bool:
560
+ """Disable a plugin."""
561
+ if name not in self.plugins:
562
+ return False
563
+ self.plugins[name].enabled = False
564
+ self.disabled_plugins.add(name)
565
+ self._save_config()
566
+ return True
567
+
568
+ def toggle(self, name: str) -> Optional[bool]:
569
+ """Toggle a plugin. Returns new state or None if not found."""
570
+ if name not in self.plugins:
571
+ return None
572
+ if self.plugins[name].enabled:
573
+ self.disable(name)
574
+ return False
575
+ else:
576
+ self.enable(name)
577
+ return True
578
+
579
+ def is_enabled(self, name: str) -> bool:
580
+ """Check if a plugin is enabled."""
581
+ if name not in self.plugins:
582
+ return False
583
+ return self.plugins[name].enabled
584
+
585
+ def run(self, name: str, args: str = "") -> Optional[str]:
586
+ """Run a plugin by name."""
587
+ p = self.plugins.get(name)
588
+ if p is None:
589
+ return f"[Error] Plugin '{name}' not found. Available: {list(self.plugins.keys())}"
590
+ if not p.enabled:
591
+ return f"[Error] Plugin '{name}' is disabled. Use '/plugins enable {name}' to enable."
592
+ return p.run(args)
593
+
594
+ def get_plugin_docs(self) -> str:
595
+ """Get documentation for all enabled plugins."""
596
+ enabled_plugins = [p for p in self.plugins.values() if p.enabled]
597
+ if not enabled_plugins:
598
+ return ""
599
+ return "\n\n".join(["## Available Plugins\n"] + [p.get_doc() for p in enabled_plugins])
600
+
601
+ def get_all_plugin_docs(self) -> str:
602
+ """Get documentation for ALL plugins (including disabled)."""
603
+ if not self.plugins:
604
+ return ""
605
+ return "\n\n".join(["## All Plugins\n"] + [p.get_doc() for p in self.plugins.values()])
606
+
607
+ def get_commands(self) -> Dict[str, str]:
608
+ """Get dict of plugin names to descriptions (enabled only)."""
609
+ return {n: p.description for n, p in self.plugins.items() if p.enabled}
610
+
611
+ def get_all_commands(self) -> Dict[str, str]:
612
+ """Get dict of ALL plugin names to descriptions."""
613
+ return {n: p.description for n, p in self.plugins.items()}
614
+
615
+ def list_plugins(self) -> str:
616
+ """List all plugins with their status."""
617
+ if not self.plugins:
618
+ return "No plugins loaded."
619
+
620
+ lines = ["Loaded Plugins:", "=" * 50]
621
+
622
+ # Enabled plugins first
623
+ enabled = [(n, p) for n, p in self.plugins.items() if p.enabled]
624
+ disabled = [(n, p) for n, p in self.plugins.items() if not p.enabled]
625
+
626
+ if enabled:
627
+ lines.append("\n✓ Enabled:")
628
+ for name, plugin in sorted(enabled):
629
+ reload_status = " [changed]" if plugin.needs_reload() else ""
630
+ lines.append(f" • {name}: {plugin.description}{reload_status}")
631
+
632
+ if disabled:
633
+ lines.append("\n✗ Disabled:")
634
+ for name, plugin in sorted(disabled):
635
+ lines.append(f" • {name}: {plugin.description}")
636
+
637
+ lines.append(f"\nTotal: {len(self.plugins)} plugins ({len(enabled)} enabled, {len(disabled)} disabled)")
638
+
639
+ # Añadir estado de hot-reload
640
+ hr_status = self.get_hot_reload_status()
641
+ lines.append(f"\nHot-reload: {'✓ active' if hr_status['running'] else '✗ inactive'}")
642
+
643
+ return "\n".join(lines)
644
+
645
+
646
+ def run_plugin_command(args: str, plugin_mgr: PluginManager) -> str:
647
+ """Handle /plugins command for managing plugins."""
648
+ if not args.strip():
649
+ return plugin_mgr.list_plugins() + "\n\nUsage: /plugins <install|uninstall|update|available|enable|disable|toggle|reload|list|status> [plugin_name]"
650
+
651
+ parts = args.strip().split(maxsplit=1)
652
+ cmd = parts[0].lower()
653
+ name = parts[1] if len(parts) > 1 else ""
654
+
655
+ if cmd == "list" or cmd == "ls":
656
+ return plugin_mgr.list_plugins()
657
+
658
+ elif cmd == "enable" or cmd == "on":
659
+ if not name:
660
+ return "Usage: /plugins enable <plugin_name>\n\nAvailable: " + ", ".join(sorted(plugin_mgr.plugins.keys()))
661
+ if plugin_mgr.enable(name):
662
+ return f"✓ Plugin '{name}' enabled."
663
+ return f"✗ Plugin '{name}' not found.\n\nAvailable: {', '.join(sorted(plugin_mgr.plugins.keys()))}"
664
+
665
+ elif cmd == "disable" or cmd == "off":
666
+ if not name:
667
+ return "Usage: /plugins disable <plugin_name>\n\nEnabled: " + ", ".join(
668
+ sorted(n for n, p in plugin_mgr.plugins.items() if p.enabled)
669
+ )
670
+ if plugin_mgr.disable(name):
671
+ return f"✓ Plugin '{name}' disabled."
672
+ return f"✗ Plugin '{name}' not found."
673
+
674
+ elif cmd == "toggle":
675
+ if not name:
676
+ return "Usage: /plugins toggle <plugin_name>"
677
+ result = plugin_mgr.toggle(name)
678
+ if result is None:
679
+ return f"✗ Plugin '{name}' not found."
680
+ status = "enabled" if result else "disabled"
681
+ return f"✓ Plugin '{name}' {status}."
682
+
683
+ elif cmd == "reload":
684
+ if name:
685
+ if plugin_mgr.reload_plugin(name):
686
+ return f"✓ Plugin '{name}' reloaded."
687
+ return f"✗ Plugin '{name}' not found or error reloading."
688
+ else:
689
+ plugin_mgr.reload()
690
+ return f"✓ All plugins reloaded.\n\n{plugin_mgr.list_plugins()}"
691
+
692
+ elif cmd == "status":
693
+ if name:
694
+ if name not in plugin_mgr.plugins:
695
+ return f"Plugin '{name}' not found."
696
+ p = plugin_mgr.plugins[name]
697
+ status = "enabled" if p.enabled else "disabled"
698
+ hot_reload = plugin_mgr.get_hot_reload_status()
699
+ return f"Plugin: {name}\nStatus: {status}\nDescription: {p.description}\nUsage: {p.usage}\nFile: {p.path}\nHot-reload: {'active' if hot_reload['running'] else 'inactive'}"
700
+ return plugin_mgr.list_plugins()
701
+
702
+ elif cmd == "hot-reload":
703
+ status = plugin_mgr.get_hot_reload_status()
704
+ if name == "start":
705
+ plugin_mgr.start_hot_reload_watcher()
706
+ return "✓ Hot-reload watcher started."
707
+ elif name == "stop":
708
+ plugin_mgr.stop_hot_reload_watcher()
709
+ return "✓ Hot-reload watcher stopped."
710
+ else:
711
+ return f"Hot-reload status: {'active' if status['running'] else 'inactive'}\nPlugins watched: {status['plugins_watched']}\nUsage: /plugins hot-reload start|stop"
712
+
713
+ elif cmd == "install" or cmd == "i":
714
+ if not name:
715
+ return "Usage: /plugins install <plugin_name>\n\nBrowse plugins: " + PLUGINS_REPO_PAGE
716
+ result = plugin_mgr.install_plugin(name)
717
+ if result["success"]:
718
+ return result["message"]
719
+ return f"✗ {result['error']}"
720
+
721
+ elif cmd == "uninstall" or cmd == "remove" or cmd == "rm":
722
+ if not name:
723
+ return "Usage: /plugins uninstall <plugin_name>\n\nInstalled: " + ", ".join(sorted(plugin_mgr.plugins.keys()))
724
+ result = plugin_mgr.uninstall_plugin(name)
725
+ if result["success"]:
726
+ return result["message"]
727
+ return f"✗ {result['error']}"
728
+
729
+ elif cmd == "update" or cmd == "upgrade":
730
+ if not name:
731
+ return "Usage: /plugins update <plugin_name>\n\nInstalled: " + ", ".join(sorted(plugin_mgr.plugins.keys()))
732
+ result = plugin_mgr.update_plugin(name)
733
+ if result["success"]:
734
+ return result["message"]
735
+ return f"✗ {result['error']}"
736
+
737
+ elif cmd == "available" or cmd == "browse" or cmd == "search":
738
+ result = plugin_mgr.list_available_plugins()
739
+ if result["success"] and result.get("plugins"):
740
+ lines = ["📦 Available Plugins in Repository:", "=" * 50, ""]
741
+ for p in result["plugins"]:
742
+ installed = "✓ [installed]" if p["name"] in plugin_mgr.plugins else ""
743
+ lines.append(f" • {p['name']} {installed}")
744
+ lines.append(f"\nTotal: {len(result['plugins'])} plugins available")
745
+ lines.append(f"\nInstall with: /plugins install <name>")
746
+ lines.append(f"Browse at: {PLUGINS_REPO_PAGE}")
747
+ return "\n".join(lines)
748
+ elif result.get("message"):
749
+ return result["message"]
750
+ else:
751
+ return f"Browse plugins at: {PLUGINS_REPO_PAGE}"
752
+
753
+ else:
754
+ return f"Unknown command: {cmd}\n\nCommands: install, uninstall, update, available, enable, disable, toggle, reload, list, status, hot-reload"