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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/plugin_manager.py
ADDED
|
@@ -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"
|