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
hanus/skill_manager.py ADDED
@@ -0,0 +1,702 @@
1
+ # hanus/skill_manager.py — Sistema de Skills
2
+ """
3
+ Gestión de skills: comandos reutilizables que pueden instalarse desde URLs.
4
+
5
+ Soporta múltiples formatos para compatibilidad:
6
+ 1. Python (.py) - Skills ejecutables con función run()
7
+ 2. Markdown (.md) - Plantillas de instrucciones para el agente
8
+ 3. Claude Code (SKILL.md) - Formato de Claude Code con directorios
9
+ 4. Cursor (.mdc) - Formato de Cursor con scoping por globs
10
+
11
+ Formatos de argumentos soportados:
12
+ - HanusCode: {{args}}, {{arg1}}, {{arg2}}
13
+ - Claude Code: $ARGUMENTS, $ARGUMENTS[0], $name (de frontmatter)
14
+ - Genérico: $0, $1, $2
15
+
16
+ Contexto dinámico:
17
+ - Claude Code: !`command` ejecuta comando y reemplaza
18
+ """
19
+ from __future__ import annotations
20
+ import importlib.util
21
+ import subprocess
22
+ import urllib.request
23
+ import urllib.error
24
+ import json
25
+ import re
26
+ import yaml
27
+ import shlex
28
+ import traceback
29
+ from pathlib import Path
30
+ from typing import Dict, List, Optional, Any
31
+ from dataclasses import dataclass, field
32
+ from enum import Enum
33
+
34
+
35
+ class SkillType(Enum):
36
+ PYTHON = "python"
37
+ MARKDOWN = "markdown"
38
+ CLAUDE_CODE = "claude_code" # SKILL.md format
39
+ CURSOR = "cursor" # .mdc format
40
+
41
+
42
+ # Directorios de skills
43
+ SKILLS_DIR_BUILTIN = Path(__file__).parent / "skills"
44
+ SKILLS_DIR_USER = Path.home() / ".hanus" / "skills"
45
+
46
+
47
+ @dataclass
48
+ class Skill:
49
+ """Representa un skill cargado."""
50
+ name: str
51
+ description: str
52
+ usage: str
53
+ version: str
54
+ author: str
55
+ path: Path
56
+ skill_type: SkillType = SkillType.PYTHON
57
+ module: Any = None
58
+ content: str = "" # Para skills markdown
59
+ is_builtin: bool = False
60
+ # Claude Code specific
61
+ arguments: List[str] = field(default_factory=list) # Named arguments
62
+ allowed_tools: List[str] = field(default_factory=list)
63
+ disable_auto_invoke: bool = False
64
+ # Cursor specific
65
+ globs: List[str] = field(default_factory=list)
66
+ always_apply: bool = False
67
+
68
+ def run(self, args: str = "") -> str:
69
+ """Ejecuta el skill."""
70
+ if self.skill_type in (SkillType.MARKDOWN, SkillType.CLAUDE_CODE, SkillType.CURSOR):
71
+ return self._run_markdown(args)
72
+ else:
73
+ return self._run_python(args)
74
+
75
+ def _run_python(self, args: str = "") -> str:
76
+ """Ejecuta un skill Python."""
77
+ try:
78
+ if self.module is None:
79
+ return f"[Skill {self.name}] Módulo no cargado"
80
+ fn = getattr(self.module, "run", None)
81
+ if not callable(fn):
82
+ return f"[Skill {self.name}] No tiene función run()"
83
+ return str(fn(args))
84
+ except Exception as e:
85
+ return f"[Skill {self.name}] Error: {e}\n{traceback.format_exc()}"
86
+
87
+ def _run_markdown(self, args: str = "") -> str:
88
+ """Ejecuta un skill Markdown (inyecta instrucciones)."""
89
+ content = self.content
90
+
91
+ # Procesar contexto dinámico (!command)
92
+ content = self._process_dynamic_context(content)
93
+
94
+ # Reemplazar argumentos en múltiples formatos
95
+ content = self._replace_arguments(content, args)
96
+
97
+ # Formatear como instrucción para el agente
98
+ output = [
99
+ f"[SKILL: {self.name}]",
100
+ "",
101
+ content,
102
+ ]
103
+
104
+ if args:
105
+ output.insert(1, f"**Argumentos:** {args}")
106
+
107
+ return "\n".join(output)
108
+
109
+ def _process_dynamic_context(self, content: str) -> str:
110
+ """Procesa comandos dinámicos estilo Claude Code (!`command`)."""
111
+ # Buscar patrón !`command` o !`command args`
112
+ pattern = r'!`([^`]+)`'
113
+
114
+ def run_command(match):
115
+ cmd = match.group(1).strip()
116
+ try:
117
+ result = subprocess.run(
118
+ cmd,
119
+ shell=True,
120
+ capture_output=True,
121
+ text=True,
122
+ timeout=30
123
+ )
124
+ output = result.stdout.strip()
125
+ if result.returncode != 0 and result.stderr:
126
+ output += f"\n[stderr: {result.stderr.strip()}]"
127
+ return output
128
+ except subprocess.TimeoutExpired:
129
+ return "[timeout]"
130
+ except Exception as e:
131
+ return f"[error: {e}]"
132
+
133
+ return re.sub(pattern, run_command, content)
134
+
135
+ def _replace_arguments(self, content: str, args: str) -> str:
136
+ """Reemplaza argumentos en múltiples formatos."""
137
+ args_parts = args.split()
138
+
139
+ # IMPORTANTE: Reemplazar $ARGUMENTS[n] ANTES de $ARGUMENTS
140
+ # para evitar que el reemplazo de $ARGUMENTS afecte los índices
141
+
142
+ # $ARGUMENTS[0], $ARGUMENTS[1], etc.
143
+ for i in range(len(args_parts)):
144
+ content = content.replace(f"$ARGUMENTS[{i}]", args_parts[i])
145
+ content = content.replace(f"${i}", args_parts[i])
146
+
147
+ # Formato Claude Code: $ARGUMENTS (todo el string)
148
+ content = content.replace("$ARGUMENTS", args)
149
+
150
+ # Named arguments from frontmatter (Claude Code style)
151
+ for i, arg_name in enumerate(self.arguments):
152
+ if i < len(args_parts):
153
+ content = content.replace(f"${arg_name}", args_parts[i])
154
+
155
+ # Formato HanusCode: {{args}}, {{arg1}}, {{arg2}}
156
+ content = content.replace("{{args}}", args)
157
+ for i, arg in enumerate(args_parts, 1):
158
+ content = content.replace(f"{{{{arg{i}}}}}", arg)
159
+
160
+ return content
161
+
162
+ def get_doc(self) -> str:
163
+ """Retorna documentación del skill."""
164
+ type_icons = {
165
+ SkillType.PYTHON: "🐍",
166
+ SkillType.MARKDOWN: "📝",
167
+ SkillType.CLAUDE_CODE: "🤖",
168
+ SkillType.CURSOR: "⚡",
169
+ }
170
+ icon = type_icons.get(self.skill_type, "📄")
171
+
172
+ lines = [
173
+ f"### {icon} Skill: /{self.name}",
174
+ f"**Descripción:** {self.description}",
175
+ f"**Uso:** `/{self.name} {self.usage}`",
176
+ f"**Versión:** {self.version}",
177
+ f"**Autor:** {self.author}",
178
+ f"**Tipo:** {self.skill_type.value}",
179
+ f"**Origen:** {'Builtin' if self.is_builtin else 'Instalado'}",
180
+ ]
181
+
182
+ if self.arguments:
183
+ lines.append(f"**Argumentos:** {', '.join(self.arguments)}")
184
+
185
+ if self.globs:
186
+ lines.append(f"**Aplica a:** {', '.join(self.globs)}")
187
+
188
+ # Para skills Python, añadir docstrings
189
+ if self.skill_type == SkillType.PYTHON and self.module:
190
+ if self.module.__doc__:
191
+ lines.append(f"\n{self.module.__doc__}")
192
+ agent_doc = getattr(self.module, "AGENT_DOC", "")
193
+ if agent_doc:
194
+ lines.append(f"\n{agent_doc}")
195
+
196
+ # Para skills Markdown, mostrar preview
197
+ elif self.content:
198
+ preview = self.content[:300] + "..." if len(self.content) > 300 else self.content
199
+ lines.append(f"\n**Preview:**")
200
+ lines.append(f"```markdown")
201
+ lines.append(preview)
202
+ lines.append(f"```")
203
+
204
+ return "\n".join(lines)
205
+
206
+ def get_prompt(self, args: str = "") -> str:
207
+ """Obtiene el prompt/instrucción para el agente."""
208
+ if self.skill_type != SkillType.PYTHON:
209
+ return self._run_markdown(args)
210
+ else:
211
+ return self.run(args)
212
+
213
+
214
+ class SkillManager:
215
+ """Gestiona el ciclo de vida de los skills."""
216
+
217
+ def __init__(self):
218
+ self.skills: Dict[str, Skill] = {}
219
+ self._load_all()
220
+
221
+ def _load_all(self):
222
+ """Carga todos los skills de todos los directorios."""
223
+ self.skills.clear()
224
+
225
+ # Cargar skills builtin
226
+ if SKILLS_DIR_BUILTIN.exists():
227
+ self._load_from_directory(SKILLS_DIR_BUILTIN, is_builtin=True)
228
+
229
+ # Cargar skills del usuario
230
+ if SKILLS_DIR_USER.exists():
231
+ self._load_from_directory(SKILLS_DIR_USER, is_builtin=False)
232
+
233
+ def _load_from_directory(self, directory: Path, is_builtin: bool = False):
234
+ """Carga skills de un directorio con múltiples formatos."""
235
+ # Python skills (.py)
236
+ for py in sorted(directory.glob("*.py")):
237
+ if not py.name.startswith("_"):
238
+ self._load_python(py, is_builtin=is_builtin)
239
+
240
+ # Markdown skills (.md)
241
+ for md in sorted(directory.glob("*.md")):
242
+ if not md.name.startswith("_"):
243
+ self._load_markdown(md, is_builtin=is_builtin)
244
+
245
+ # Cursor skills (.mdc)
246
+ for mdc in sorted(directory.glob("*.mdc")):
247
+ if not mdc.name.startswith("_"):
248
+ self._load_cursor(mdc, is_builtin=is_builtin)
249
+
250
+ # Claude Code skills (SKILL.md en subdirectorios)
251
+ for skill_dir in sorted(directory.iterdir()):
252
+ if skill_dir.is_dir() and not skill_dir.name.startswith("_"):
253
+ skill_md = skill_dir / "SKILL.md"
254
+ if skill_md.exists():
255
+ self._load_claude_code(skill_md, is_builtin=is_builtin)
256
+
257
+ def _load_python(self, path: Path, is_builtin: bool = False) -> Optional[Skill]:
258
+ """Carga un skill Python desde archivo."""
259
+ try:
260
+ import sys
261
+ module_name = f"hanus_skill_{path.stem}"
262
+
263
+ spec = importlib.util.spec_from_file_location(module_name, path)
264
+ if not spec or not spec.loader:
265
+ return None
266
+
267
+ module = importlib.util.module_from_spec(spec)
268
+ sys.modules[module_name] = module
269
+ spec.loader.exec_module(module)
270
+
271
+ name = getattr(module, "NAME", path.stem).lower().replace("-", "_")
272
+ description = getattr(module, "DESCRIPTION", "Sin descripción")
273
+ usage = getattr(module, "USAGE", "")
274
+ version = getattr(module, "VERSION", "1.0")
275
+ author = getattr(module, "AUTHOR", "Desconocido")
276
+
277
+ skill = Skill(
278
+ name=name,
279
+ description=description,
280
+ usage=usage,
281
+ version=version,
282
+ author=author,
283
+ path=path,
284
+ skill_type=SkillType.PYTHON,
285
+ module=module,
286
+ is_builtin=is_builtin,
287
+ )
288
+
289
+ self.skills[name] = skill
290
+ return skill
291
+
292
+ except Exception as e:
293
+ print(f" [SkillManager] Error cargando Python skill '{path.name}': {e}")
294
+ return None
295
+
296
+ def _load_markdown(self, path: Path, is_builtin: bool = False) -> Optional[Skill]:
297
+ """Carga un skill Markdown (formato HanusCode o Claude Code simple)."""
298
+ try:
299
+ content = path.read_text(encoding="utf-8")
300
+
301
+ metadata = self._parse_frontmatter(content, path)
302
+ body = content
303
+
304
+ # Separar frontmatter del body
305
+ if content.startswith("---"):
306
+ parts = content.split("---", 2)
307
+ if len(parts) >= 3:
308
+ body = parts[2].strip()
309
+
310
+ # Determinar si es formato Claude Code
311
+ is_claude_format = "arguments" in metadata or "allowed-tools" in metadata
312
+
313
+ skill = Skill(
314
+ name=str(metadata.get("name", path.stem)).lower().replace("-", "_"),
315
+ description=str(metadata.get("description", "Sin descripción")),
316
+ usage=str(metadata.get("usage", "")),
317
+ version=str(metadata.get("version", "1.0")),
318
+ author=str(metadata.get("author", metadata.get("created-by", "Desconocido"))),
319
+ path=path,
320
+ skill_type=SkillType.CLAUDE_CODE if is_claude_format else SkillType.MARKDOWN,
321
+ content=body,
322
+ is_builtin=is_builtin,
323
+ arguments=metadata.get("arguments", []),
324
+ allowed_tools=metadata.get("allowed-tools", []),
325
+ disable_auto_invoke=metadata.get("disable-model-invocation", False),
326
+ )
327
+
328
+ self.skills[skill.name] = skill
329
+ return skill
330
+
331
+ except Exception as e:
332
+ print(f" [SkillManager] Error cargando Markdown skill '{path.name}': {e}")
333
+ return None
334
+
335
+ def _load_claude_code(self, path: Path, is_builtin: bool = False) -> Optional[Skill]:
336
+ """Carga un skill en formato Claude Code (SKILL.md en directorio)."""
337
+ try:
338
+ content = path.read_text(encoding="utf-8")
339
+ metadata = self._parse_frontmatter(content, path)
340
+
341
+ # Separar body
342
+ body = content
343
+ if content.startswith("---"):
344
+ parts = content.split("---", 2)
345
+ if len(parts) >= 3:
346
+ body = parts[2].strip()
347
+
348
+ # Nombre del directorio como fallback
349
+ dir_name = path.parent.name.lower().replace("-", "_")
350
+ name = str(metadata.get("name", dir_name)).lower().replace("-", "_")
351
+
352
+ skill = Skill(
353
+ name=name,
354
+ description=str(metadata.get("description", "Sin descripción")),
355
+ usage=str(metadata.get("usage", "")),
356
+ version=str(metadata.get("version", "1.0")),
357
+ author=str(metadata.get("created-by", metadata.get("author", "Desconocido"))),
358
+ path=path,
359
+ skill_type=SkillType.CLAUDE_CODE,
360
+ content=body,
361
+ is_builtin=is_builtin,
362
+ arguments=metadata.get("arguments", []),
363
+ allowed_tools=self._parse_allowed_tools(metadata.get("allowed-tools", [])),
364
+ disable_auto_invoke=metadata.get("disable-model-invocation", False),
365
+ )
366
+
367
+ self.skills[name] = skill
368
+ return skill
369
+
370
+ except Exception as e:
371
+ print(f" [SkillManager] Error cargando Claude Code skill '{path}': {e}")
372
+ return None
373
+
374
+ def _load_cursor(self, path: Path, is_builtin: bool = False) -> Optional[Skill]:
375
+ """Carga un skill en formato Cursor (.mdc)."""
376
+ try:
377
+ content = path.read_text(encoding="utf-8")
378
+ metadata = self._parse_frontmatter(content, path)
379
+
380
+ # Separar body
381
+ body = content
382
+ if content.startswith("---"):
383
+ parts = content.split("---", 2)
384
+ if len(parts) >= 3:
385
+ body = parts[2].strip()
386
+
387
+ name = path.stem.lower().replace("-", "_")
388
+
389
+ skill = Skill(
390
+ name=name,
391
+ description=str(metadata.get("description", "Sin descripción")),
392
+ usage="",
393
+ version="1.0",
394
+ author="Cursor",
395
+ path=path,
396
+ skill_type=SkillType.CURSOR,
397
+ content=body,
398
+ is_builtin=is_builtin,
399
+ globs=metadata.get("globs", []),
400
+ always_apply=metadata.get("alwaysApply", False),
401
+ )
402
+
403
+ self.skills[name] = skill
404
+ return skill
405
+
406
+ except Exception as e:
407
+ print(f" [SkillManager] Error cargando Cursor skill '{path.name}': {e}")
408
+ return None
409
+
410
+ def _parse_frontmatter(self, content: str, path: Path) -> Dict:
411
+ """Parsea frontmatter YAML de un archivo."""
412
+ metadata = {"name": path.stem.lower().replace("-", "_")}
413
+
414
+ if content.startswith("---"):
415
+ parts = content.split("---", 2)
416
+ if len(parts) >= 2:
417
+ frontmatter = parts[1].strip()
418
+ try:
419
+ fm_data = yaml.safe_load(frontmatter) or {}
420
+ metadata.update(fm_data)
421
+ except yaml.YAMLError:
422
+ pass
423
+
424
+ # Normalizar arguments (puede ser lista o string)
425
+ if "arguments" in metadata and isinstance(metadata["arguments"], str):
426
+ # Formato: "issue branch" -> ["issue", "branch"]
427
+ metadata["arguments"] = metadata["arguments"].split()
428
+
429
+ return metadata
430
+
431
+ def _parse_allowed_tools(self, tools) -> List[str]:
432
+ """Parsea allowed-tools que puede ser string, lista o None."""
433
+ if tools is None:
434
+ return []
435
+ if isinstance(tools, str):
436
+ return tools.split()
437
+ return list(tools)
438
+
439
+ def reload(self):
440
+ """Recarga todos los skills."""
441
+ self._load_all()
442
+
443
+ def get(self, name: str) -> Optional[Skill]:
444
+ """Obtiene un skill por nombre."""
445
+ return self.skills.get(name.lower())
446
+
447
+ def list_skills(self) -> List[Skill]:
448
+ """Lista todos los skills."""
449
+ return list(self.skills.values())
450
+
451
+ def run(self, name: str, args: str = "") -> str:
452
+ """Ejecuta un skill."""
453
+ skill = self.get(name)
454
+ if skill is None:
455
+ available = list(self.skills.keys())
456
+ return f"[Error] Skill '{name}' no encontrado. Disponibles: {available}"
457
+ return skill.run(args)
458
+
459
+ def install(self, url: str, name: Optional[str] = None) -> str:
460
+ """Instala un skill desde una URL (soporta múltiples formatos)."""
461
+ SKILLS_DIR_USER.mkdir(parents=True, exist_ok=True)
462
+
463
+ try:
464
+ req = urllib.request.Request(url, headers={
465
+ "User-Agent": "HanusCode/1.0"
466
+ })
467
+
468
+ with urllib.request.urlopen(req, timeout=30) as resp:
469
+ content = resp.read().decode("utf-8")
470
+
471
+ # Detectar tipo de skill
472
+ if url.endswith(".mdc"):
473
+ return self._install_cursor(content, url, name)
474
+ elif url.endswith("SKILL.md") or "SKILL.md" in url:
475
+ return self._install_claude_code(content, url, name)
476
+ elif url.endswith(".md") or content.strip().startswith("---"):
477
+ return self._install_markdown(content, url, name)
478
+ else:
479
+ return self._install_python(content, url, name)
480
+
481
+ except urllib.error.HTTPError as e:
482
+ return f"Error HTTP {e.code}: {e.reason}"
483
+ except urllib.error.URLError as e:
484
+ return f"Error de conexión: {e.reason}"
485
+ except Exception as e:
486
+ return f"Error instalando skill: {e}"
487
+
488
+ def _install_python(self, content: str, url: str, name: Optional[str] = None) -> str:
489
+ """Instala un skill Python."""
490
+ if "def run(" not in content:
491
+ return "Error: El archivo no es un skill Python válido (falta función run())"
492
+
493
+ if not name:
494
+ name_match = re.search(r'NAME\s*=\s*["\'](\w+)["\']', content)
495
+ name = name_match.group(1) if name_match else Path(url).stem
496
+
497
+ name = name.lower().replace("-", "_").replace(" ", "_")
498
+ dest_path = SKILLS_DIR_USER / f"{name}.py"
499
+ dest_path.write_text(content, encoding="utf-8")
500
+
501
+ skill = self._load_python(dest_path, is_builtin=False)
502
+ return f"✓ Skill Python '{skill.name}' instalado\n{skill.get_doc()}" if skill else f"✓ Guardado en {dest_path}"
503
+
504
+ def _install_markdown(self, content: str, url: str, name: Optional[str] = None) -> str:
505
+ """Instala un skill Markdown."""
506
+ metadata = self._parse_frontmatter(content, Path(url))
507
+ name = name or metadata.get("name", Path(url).stem)
508
+ name = name.lower().replace("-", "_").replace(" ", "_")
509
+
510
+ dest_path = SKILLS_DIR_USER / f"{name}.md"
511
+ dest_path.write_text(content, encoding="utf-8")
512
+
513
+ skill = self._load_markdown(dest_path, is_builtin=False)
514
+ return f"✓ Skill Markdown '{skill.name}' instalado\n{skill.get_doc()}" if skill else f"✓ Guardado en {dest_path}"
515
+
516
+ def _install_claude_code(self, content: str, url: str, name: Optional[str] = None) -> str:
517
+ """Instala un skill Claude Code (crea directorio con SKILL.md)."""
518
+ metadata = self._parse_frontmatter(content, Path(url))
519
+ skill_name = name or metadata.get("name", "imported")
520
+ skill_name = skill_name.lower().replace("-", "_").replace(" ", "_")
521
+
522
+ # Crear directorio para el skill
523
+ skill_dir = SKILLS_DIR_USER / skill_name
524
+ skill_dir.mkdir(parents=True, exist_ok=True)
525
+
526
+ dest_path = skill_dir / "SKILL.md"
527
+ dest_path.write_text(content, encoding="utf-8")
528
+
529
+ skill = self._load_claude_code(dest_path, is_builtin=False)
530
+ return f"✓ Skill Claude Code '{skill.name}' instalado\n{skill.get_doc()}" if skill else f"✓ Guardado en {dest_path}"
531
+
532
+ def _install_cursor(self, content: str, url: str, name: Optional[str] = None) -> str:
533
+ """Instala un skill Cursor (.mdc)."""
534
+ name = name or Path(url).stem
535
+ name = name.lower().replace("-", "_").replace(" ", "_")
536
+
537
+ dest_path = SKILLS_DIR_USER / f"{name}.mdc"
538
+ dest_path.write_text(content, encoding="utf-8")
539
+
540
+ skill = self._load_cursor(dest_path, is_builtin=False)
541
+ return f"✓ Skill Cursor '{skill.name}' instalado\n{skill.get_doc()}" if skill else f"✓ Guardado en {dest_path}"
542
+
543
+ def install_from_gist(self, gist_url: str) -> str:
544
+ """Instala un skill desde un GitHub Gist."""
545
+ gist_id = gist_url.split("/")[-1].split("?")[0]
546
+ api_url = f"https://api.github.com/gists/{gist_id}"
547
+
548
+ try:
549
+ req = urllib.request.Request(api_url, headers={
550
+ "User-Agent": "HanusCode/1.0",
551
+ "Accept": "application/vnd.github.v3+json"
552
+ })
553
+
554
+ with urllib.request.urlopen(req, timeout=30) as resp:
555
+ data = json.loads(resp.read().decode("utf-8"))
556
+
557
+ for filename, file_data in data.get("files", {}).items():
558
+ content = file_data.get("content", "")
559
+ if not content:
560
+ continue
561
+
562
+ SKILLS_DIR_USER.mkdir(parents=True, exist_ok=True)
563
+
564
+ if filename.endswith(".mdc"):
565
+ dest_path = SKILLS_DIR_USER / filename
566
+ dest_path.write_text(content, encoding="utf-8")
567
+ skill = self._load_cursor(dest_path, is_builtin=False)
568
+ elif filename == "SKILL.md":
569
+ # Claude Code format - crear directorio
570
+ skill_dir = SKILLS_DIR_USER / Path(filename).stem
571
+ skill_dir.mkdir(parents=True, exist_ok=True)
572
+ dest_path = skill_dir / "SKILL.md"
573
+ dest_path.write_text(content, encoding="utf-8")
574
+ skill = self._load_claude_code(dest_path, is_builtin=False)
575
+ elif filename.endswith(".md"):
576
+ dest_path = SKILLS_DIR_USER / filename
577
+ dest_path.write_text(content, encoding="utf-8")
578
+ skill = self._load_markdown(dest_path, is_builtin=False)
579
+ elif filename.endswith(".py"):
580
+ dest_path = SKILLS_DIR_USER / filename
581
+ dest_path.write_text(content, encoding="utf-8")
582
+ skill = self._load_python(dest_path, is_builtin=False)
583
+ else:
584
+ continue
585
+
586
+ if skill:
587
+ return f"✓ Skill '{skill.name}' instalado desde Gist\n{skill.get_doc()}"
588
+
589
+ return "Error: No se encontró ningún archivo de skill en el Gist"
590
+
591
+ except Exception as e:
592
+ return f"Error instalando desde Gist: {e}"
593
+
594
+ def create_markdown(self, name: str, description: str, content: str = "") -> str:
595
+ """Crea un nuevo skill Markdown."""
596
+ SKILLS_DIR_USER.mkdir(parents=True, exist_ok=True)
597
+
598
+ name = name.lower().replace("-", "_").replace(" ", "_")
599
+ dest_path = SKILLS_DIR_USER / f"{name}.md"
600
+
601
+ if dest_path.exists():
602
+ return f"Error: Ya existe un skill con nombre '{name}'"
603
+
604
+ template = f"""---
605
+ name: {name}
606
+ description: {description}
607
+ version: "1.0"
608
+ author: "User"
609
+ ---
610
+
611
+ # {description.title()}
612
+
613
+ {content or "Instrucciones para el agente..."}
614
+ """
615
+
616
+ dest_path.write_text(template, encoding="utf-8")
617
+ skill = self._load_markdown(dest_path, is_builtin=False)
618
+
619
+ return f"✓ Skill '{skill.name}' creado\nArchivo: {dest_path}" if skill else "Error creando skill"
620
+
621
+ def remove(self, name: str) -> str:
622
+ """Elimina un skill instalado."""
623
+ skill = self.get(name)
624
+
625
+ if skill is None:
626
+ return f"Error: Skill '{name}' no encontrado"
627
+
628
+ if skill.is_builtin:
629
+ return f"Error: No se pueden eliminar skills builtin"
630
+
631
+ try:
632
+ # Si es Claude Code format, eliminar directorio
633
+ if skill.skill_type == SkillType.CLAUDE_CODE and skill.path.name == "SKILL.md":
634
+ import shutil
635
+ shutil.rmtree(skill.path.parent)
636
+ else:
637
+ skill.path.unlink()
638
+
639
+ del self.skills[name.lower()]
640
+ return f"✓ Skill '{name}' eliminado"
641
+ except Exception as e:
642
+ return f"Error eliminando skill: {e}"
643
+
644
+ def get_skill_docs(self) -> str:
645
+ """Retorna documentación de todos los skills."""
646
+ if not self.skills:
647
+ return ""
648
+
649
+ docs = ["## Skills disponibles\n"]
650
+ docs.append("Los skills son comandos reutilizables. Invócalos con `/nombre_skill`")
651
+ docs.append("")
652
+
653
+ # Agrupar por tipo
654
+ by_type = {
655
+ SkillType.MARKDOWN: [],
656
+ SkillType.PYTHON: [],
657
+ SkillType.CLAUDE_CODE: [],
658
+ SkillType.CURSOR: [],
659
+ }
660
+
661
+ for skill in self.skills.values():
662
+ by_type[skill.skill_type].append(skill)
663
+
664
+ type_labels = {
665
+ SkillType.MARKDOWN: ("📝 Skills Markdown", "HanusCode format"),
666
+ SkillType.PYTHON: ("🐍 Skills Python", "Ejecutables"),
667
+ SkillType.CLAUDE_CODE: ("🤖 Skills Claude Code", "Compatible"),
668
+ SkillType.CURSOR: ("⚡ Skills Cursor", "Compatible"),
669
+ }
670
+
671
+ for skill_type, skills_list in by_type.items():
672
+ if skills_list:
673
+ label, hint = type_labels[skill_type]
674
+ docs.append(f"### {label} ({hint})")
675
+ for skill in sorted(skills_list, key=lambda s: s.name):
676
+ docs.append(f"- `/{skill.name}` — {skill.description}")
677
+ docs.append("")
678
+
679
+ return "\n".join(docs)
680
+
681
+ def get_commands(self) -> Dict[str, str]:
682
+ """Retorna diccionario de comandos para UI."""
683
+ type_prefix = {
684
+ SkillType.MARKDOWN: "[MD]",
685
+ SkillType.PYTHON: "[PY]",
686
+ SkillType.CLAUDE_CODE: "[CC]",
687
+ SkillType.CURSOR: "[MC]",
688
+ }
689
+ return {name: f"{type_prefix.get(s.skill_type, '')} {s.description}"
690
+ for name, s in self.skills.items()}
691
+
692
+
693
+ # Singleton global
694
+ _skill_manager: Optional[SkillManager] = None
695
+
696
+
697
+ def get_skill_manager() -> SkillManager:
698
+ """Obtiene el gestor de skills."""
699
+ global _skill_manager
700
+ if _skill_manager is None:
701
+ _skill_manager = SkillManager()
702
+ return _skill_manager