@cristiancorreau/forge 2.9.5 → 2.9.7

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 (50) hide show
  1. package/assets/adapters/claude-code/commands/new-feature.md +10 -5
  2. package/assets/adapters/claude-code/commands/plan.md +61 -36
  3. package/assets/adapters/claude-code/commands/session-start.md +1 -1
  4. package/assets/adapters/claude-code/commands/ship.md +6 -4
  5. package/assets/adapters/claude-code/commands/work.md +8 -6
  6. package/assets/core/skills/README.md +2 -2
  7. package/assets/core/skills/aitmpl-search/SKILL.md +7 -19
  8. package/assets/core/skills/local2prod/SKILL.md +1 -1
  9. package/assets/core/skills/new-feature/SKILL.md +1 -1
  10. package/assets/core/skills/phase-kickoff/SKILL.md +2 -0
  11. package/assets/core/skills/spec/SKILL.md +2 -0
  12. package/assets/core/skills/wiki-ingest/SKILL.md +7 -7
  13. package/assets/core/skills/wiki-lint/SKILL.md +4 -4
  14. package/assets/core/skills/wiki-query/SKILL.md +3 -3
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +2 -1
  17. package/dist/commands/doctor.js.map +1 -1
  18. package/dist/commands/init.js +1 -1
  19. package/dist/lib/paths.d.ts +1 -2
  20. package/dist/lib/paths.d.ts.map +1 -1
  21. package/dist/lib/paths.js +12 -16
  22. package/dist/lib/paths.js.map +1 -1
  23. package/dist/version.d.ts +1 -1
  24. package/dist/version.js +1 -1
  25. package/package.json +2 -2
  26. package/assets/adapters/claude-code/generate-claude-md.py +0 -304
  27. package/assets/adapters/codex/generate-codex-config.py +0 -269
  28. package/assets/adapters/kiro/generate-steering.py +0 -367
  29. package/assets/adapters/opencode/generate-agents-md.py +0 -262
  30. package/assets/core/hooks/pre-bash-check.py +0 -202
  31. package/assets/core/hooks/pre-edit-check.py +0 -317
  32. package/assets/forge.py +0 -1265
  33. package/assets/requirements.txt +0 -2
  34. package/assets/scripts/aitmpl-search.py +0 -808
  35. package/assets/scripts/forge-add-opportunities.py +0 -92
  36. package/assets/scripts/forge-audit.py +0 -1061
  37. package/assets/scripts/forge-generate-all.py +0 -283
  38. package/assets/scripts/forge-init.py +0 -900
  39. package/assets/scripts/forge-migrate-project-yaml.py +0 -397
  40. package/assets/scripts/forge-scaffold-profile.py +0 -181
  41. package/assets/scripts/forge-teardown.py +0 -193
  42. package/assets/scripts/forge-validate-project-yaml.py +0 -457
  43. package/assets/scripts/forge-wizard.py +0 -1003
  44. package/assets/scripts/setup-codex.sh +0 -229
  45. package/assets/scripts/team-install.sh +0 -147
  46. package/assets/scripts/token-stats.py +0 -201
  47. package/dist/lib/python.d.ts +0 -4
  48. package/dist/lib/python.d.ts.map +0 -1
  49. package/dist/lib/python.js +0 -46
  50. package/dist/lib/python.js.map +0 -1
@@ -1,1061 +0,0 @@
1
- #!/usr/bin/env python3
2
- # Copyright 2026 Cristian Correa — Apache License 2.0
3
- # https://github.com/cristiancorreau/forge
4
- """
5
- forge-audit.py — Auditoría del estado actual de un proyecto vs el estándar forge.
6
-
7
- Usage:
8
- python3 .agentic/scripts/forge-audit.py
9
- python3 .agentic/scripts/forge-audit.py --json # output JSON para integrar en CI
10
-
11
- Qué reporta:
12
- 1. Health check por agente (.claude/agents/): frontmatter, secciones, modelo
13
- 2. Gap analysis vs forge: diferencia entre el agente del proyecto y la versión de forge
14
- 3. Oportunidades: profiles y skills disponibles en forge que el proyecto no usa
15
- 4. Huérfanos: agentes en .claude/ que no están declarados en project.yaml
16
-
17
- Requiere: pyyaml → pip3 install -r .agentic/requirements.txt
18
- """
19
- import json
20
- import os
21
- import re
22
- import sys
23
- from difflib import SequenceMatcher
24
- from pathlib import Path
25
-
26
- try:
27
- import yaml
28
- except ImportError:
29
- print("ERROR: pip3 install -r .agentic/requirements.txt", file=sys.stderr)
30
- sys.exit(1)
31
-
32
- # ── ANSI colors (desactivar si no es TTY) ─────────────────────────────────────
33
- USE_COLOR = sys.stdout.isatty()
34
- def c(code: str, text: str) -> str:
35
- return f"\033[{code}m{text}\033[0m" if USE_COLOR else text
36
- OK = lambda t: c("32", t) # verde
37
- WARN= lambda t: c("33", t) # amarillo
38
- ERR = lambda t: c("31", t) # rojo
39
- INFO= lambda t: c("36", t) # cyan
40
- BOLD= lambda t: c("1", t) # negrita
41
- DIM = lambda t: c("2", t) # gris
42
-
43
- # ── Secciones requeridas por el estándar ──────────────────────────────────────
44
- REQUIRED_SECTIONS = ["## Reglas", "## No hagas"]
45
- OPTIONAL_SECTIONS = ["## Workflow", "## Stack", "## Tu trabajo", "## Anti-patterns"]
46
-
47
- REQUIRED_FRONTMATTER = ["name", "description", "model", "tools", "tier"]
48
-
49
- STANDARD_VERSION = "1.0"
50
-
51
- VALID_MODELS = {"opus", "sonnet", "haiku"}
52
- # Tier 1 reviewers deben usar opus
53
- OPUS_ROLES = {"compliance-reviewer", "security-auditor", "orchestrator"}
54
-
55
- # Umbrales de similitud de texto (SequenceMatcher.ratio, escala 0-1).
56
- # Calibración: agentes Tier 1 sin modificar tienen ratio ~0.95-1.0 vs forge.
57
- # Agentes con especialización moderada (añadir 20-40 líneas) caen a ~0.65-0.80.
58
- # Agentes reescritos para un dominio específico (Tier 3) caen a <0.40.
59
- # Ajustar SIMILARITY_WARN a 0.70 para proyectos con fuerte especialización.
60
- SIMILARITY_WARN = 0.80 # <80% → posibles mejoras disponibles en forge
61
- SIMILARITY_OUTDATED = 0.50 # <50% → probablemente desactualizado o fork intencional
62
-
63
-
64
- # ── Parsing ───────────────────────────────────────────────────────────────────
65
-
66
- def find_project_root() -> Path:
67
- here = Path.cwd()
68
- for p in [here] + list(here.parents):
69
- if (p / "project.yaml").exists():
70
- return p
71
- raise FileNotFoundError("No se encontró project.yaml")
72
-
73
-
74
- def find_forge_dir() -> Path:
75
- root = find_project_root()
76
- for candidate in [root / ".agentic", root / "forge", Path(__file__).parent.parent]:
77
- if (candidate / "core").exists():
78
- return candidate
79
- raise FileNotFoundError("No se encontró el directorio forge con core/")
80
-
81
-
82
- def load_project(root: Path) -> dict:
83
- with open(root / "project.yaml") as f:
84
- return yaml.safe_load(f)
85
-
86
-
87
- def parse_frontmatter(content: str) -> dict:
88
- """Extrae el bloque --- ... --- del inicio del archivo."""
89
- m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
90
- if not m:
91
- return {}
92
- try:
93
- return yaml.safe_load(m.group(1)) or {}
94
- except Exception:
95
- return {}
96
-
97
-
98
- def read_agent(path: Path) -> dict:
99
- """Lee un archivo de agente y retorna su metadata + contenido."""
100
- content = path.read_text()
101
- fm = parse_frontmatter(content)
102
- return {
103
- "path": path,
104
- "name": path.stem,
105
- "content": content,
106
- "frontmatter": fm,
107
- "lines": len(content.splitlines()),
108
- }
109
-
110
-
111
- def get_forge_version(forge, name, profiles):
112
- """
113
- Busca la versión forge de un agente.
114
- Prioridad: profiles (en orden) → core.
115
- Retorna (path, source_label) o (None, "").
116
- """
117
- for profile in profiles:
118
- p = forge / "profiles" / profile / "agents" / f"{name}.md"
119
- if p.exists():
120
- return p, f"profile:{profile}"
121
- p = forge / "core" / "agents" / f"{name}.md"
122
- if p.exists():
123
- return p, "core"
124
- return None, ""
125
-
126
-
127
- def similarity(a: str, b: str) -> float:
128
- return SequenceMatcher(None, a, b).ratio()
129
-
130
-
131
- # ── Checks ────────────────────────────────────────────────────────────────────
132
-
133
- def check_frontmatter(agent):
134
- issues = []
135
- fm = agent["frontmatter"]
136
-
137
- if not fm:
138
- issues.append({"level": "error", "msg": "Sin bloque frontmatter (--- ... ---)"})
139
- return issues
140
-
141
- for field in REQUIRED_FRONTMATTER:
142
- if field not in fm:
143
- issues.append({"level": "error", "msg": f"Falta campo '{field}' en frontmatter"})
144
-
145
- if "description" in fm:
146
- desc = str(fm["description"])
147
- if len(desc) > 120:
148
- issues.append({"level": "warn", "msg": f"description muy larga ({len(desc)} chars) — debe caber en 1 línea clara"})
149
- if "NO" not in desc.upper() and "no trabaja" not in desc.lower() and "scope" not in desc.lower():
150
- issues.append({"level": "warn", "msg": "description no declara scope explícito (qué NO hace / en qué directorio trabaja)"})
151
-
152
- if "model" in fm:
153
- model = str(fm["model"])
154
- if model not in VALID_MODELS:
155
- issues.append({"level": "error", "msg": f"model '{model}' inválido — usar: opus | sonnet | haiku"})
156
- name = agent["name"]
157
- if name in OPUS_ROLES and model != "opus":
158
- issues.append({"level": "warn", "msg": f"'{name}' debería usar model: opus (toma decisiones complejas)"})
159
- if name not in OPUS_ROLES and model == "opus":
160
- issues.append({"level": "warn", "msg": "model: opus en agente de implementación — sonnet es suficiente y más económico"})
161
-
162
- if "last_verified" in fm:
163
- try:
164
- import datetime as _dt
165
- lv = str(fm["last_verified"]) # formato "YYYY-MM"
166
- year, month = int(lv[:4]), int(lv[5:7])
167
- verified_date = _dt.date(year, month, 1)
168
- months_old = (
169
- (_dt.date.today().year - verified_date.year) * 12
170
- + _dt.date.today().month - verified_date.month
171
- )
172
- if months_old >= 6:
173
- issues.append({
174
- "level": "warn",
175
- "msg": f"last_verified hace {months_old} meses — verificar que las APIs de terceros siguen vigentes",
176
- "fix": f"Actualizar last_verified en frontmatter tras revisar la documentación del proveedor",
177
- })
178
- except Exception:
179
- pass
180
-
181
- if "standard_version" not in fm:
182
- issues.append({
183
- "field": "standard_version",
184
- "level": "info",
185
- "msg": "Campo 'standard_version' no presente — agente generado antes de standard v1.0",
186
- })
187
- elif fm.get("standard_version") != STANDARD_VERSION:
188
- issues.append({
189
- "field": "standard_version",
190
- "level": "info",
191
- "msg": f"standard_version '{fm['standard_version']}' difiere de la actual '{STANDARD_VERSION}'",
192
- })
193
-
194
- return issues
195
-
196
-
197
- def check_sections(agent):
198
- issues = []
199
- content = agent["content"]
200
-
201
- for section in REQUIRED_SECTIONS:
202
- if section not in content:
203
- # Buscar variantes comunes
204
- section_key = section.replace("## ", "").lower()
205
- variants = [f"## anti-patterns", f"## no hacer", f"## restricciones"]
206
- found_variant = any(v in content.lower() for v in variants)
207
- if not found_variant:
208
- issues.append({"level": "error", "msg": f"Falta sección '{section}' (requerida por estándar)"})
209
-
210
- if agent["lines"] < 15:
211
- issues.append({"level": "warn", "msg": f"Agente muy corto ({agent['lines']} líneas) — probablemente incompleto"})
212
-
213
- return issues
214
-
215
-
216
- def check_vs_forge(agent, forge, profiles):
217
- issues = []
218
- tier = agent["frontmatter"].get("tier")
219
-
220
- # Tier 3 (dominio): no existe en forge por diseño — correcto
221
- if tier == 3:
222
- issues.append({"level": "ok", "msg": "Tier 3 (dominio) — no se compara con forge"})
223
- return issues
224
-
225
- forge_path, source = get_forge_version(forge, agent["name"], profiles)
226
-
227
- if forge_path is None:
228
- if not tier:
229
- # Sin tier: posiblemente Tier 3 no anotado, reportar como sugerencia
230
- issues.append({"level": "info", "msg": "No encontrado en forge — si es Tier 3, agregar 'tier: 3' al frontmatter"})
231
- elif tier in (1, 2):
232
- issues.append({"level": "warn", "msg": f"Tier {tier} declarado pero no existe en forge — ¿falta crear el profile?"})
233
- return issues
234
-
235
- forge_content = forge_path.read_text()
236
- ratio = similarity(agent["content"], forge_content)
237
- forge_lines = len(forge_content.splitlines())
238
- project_lines = agent["lines"]
239
-
240
- extended = project_lines > forge_lines * 1.2 # proyecto tiene >20% más líneas
241
- # Divergencia simétrica: mismo orden de magnitud, contenido diferente (especialización)
242
- comparable = forge_lines * 0.7 <= project_lines <= forge_lines * 1.2
243
-
244
- if ratio >= SIMILARITY_WARN:
245
- issues.append({"level": "ok", "msg": f"Al día con forge ({source}) — similitud {ratio:.0%}"})
246
- elif ratio >= SIMILARITY_OUTDATED:
247
- if extended or comparable:
248
- diff_lines = project_lines - forge_lines
249
- sign = "+" if diff_lines >= 0 else ""
250
- issues.append({
251
- "level": "info",
252
- "msg": f"Especialización intencional vs forge ({source}) — similitud {ratio:.0%}, {sign}{diff_lines} líneas respecto a forge",
253
- })
254
- else:
255
- diff_lines = forge_lines - project_lines
256
- issues.append({
257
- "level": "warn",
258
- "msg": f"Posibles mejoras disponibles en forge ({source}) — similitud {ratio:.0%}, forge tiene +{diff_lines} líneas (puede ser reescritura intencional — verificar manualmente)",
259
- "may_be_intentional": True,
260
- "fix": f"python3 .agentic/scripts/forge-init.py --tool claude-code --force --only={agent['name']}"
261
- })
262
- else:
263
- if extended:
264
- diff_lines = project_lines - forge_lines
265
- issues.append({
266
- "level": "info",
267
- "msg": f"Muy extendido vs forge ({source}) — similitud {ratio:.0%}, proyecto tiene +{diff_lines} líneas (fork intencional)",
268
- })
269
- elif comparable:
270
- issues.append({
271
- "level": "warn",
272
- "msg": f"Contenido muy diferente a forge ({source}) — similitud {ratio:.0%}. ¿Especialización intencional o desactualizado?",
273
- "fix": f"Revisar manualmente vs python3 .agentic/scripts/forge-init.py --tool claude-code --force --only={agent['name']}"
274
- })
275
- else:
276
- issues.append({
277
- "level": "error",
278
- "msg": f"Muy diferente a forge ({source}) — similitud {ratio:.0%}. Probablemente desactualizado (diferencia mayor al 50% — verificar si coincide con cambio de versión de forge)",
279
- "fix": f"python3 .agentic/scripts/forge-init.py --tool claude-code --force --only={agent['name']}"
280
- })
281
-
282
- return issues
283
-
284
-
285
- # ── Relevancia de profiles por stack ─────────────────────────────────────────
286
-
287
- # Mapeo profile → condiciones que lo hacen relevante.
288
- # Una condición es un dict {campo: conjunto_de_valores_match}.
289
- # El profile es relevante si CUALQUIER condición se cumple.
290
- import textwrap as _textwrap
291
-
292
- # ── Catálogos de descripción para UI ─────────────────────────────────────────
293
-
294
- # (qué hace, qué ganás, trigger)
295
- _SKILL_INFO: dict[str, tuple[str, str, str]] = {
296
- "security-audit": (
297
- "Checklist de seguridad para endpoints, auth y datos sensibles.",
298
- "Detecta vulnerabilidades antes de cada PR. Agnóstico al stack.",
299
- "/security-audit",
300
- ),
301
- "db-migrate": (
302
- "Flujo seguro de migraciones (Prisma, Drizzle, Rails, Alembic, Goose).",
303
- "Evita pérdida de datos y conflictos al cambiar el schema.",
304
- "/db-migrate",
305
- ),
306
- "local2prod": (
307
- "Deploy completo a producción (Vercel, Railway, Fly.io, GitHub Actions).",
308
- "Garantiza que el build y el runtime estén ok antes de cerrar la tarea.",
309
- "/local2prod",
310
- ),
311
- "new-feature": (
312
- "Orquesta planificación, implementación y deploy de una feature completa.",
313
- "Mantiene el flujo consistente desde spec hasta producción.",
314
- "/new-feature",
315
- ),
316
- "browser-test": (
317
- "Automatización de navegador: screenshots y flujos visuales vía CDP.",
318
- "Verifica el UI en el browser antes de dar cualquier tarea por terminada.",
319
- "/browser-test",
320
- ),
321
- "wiki-ingest": (
322
- "Ingesta documentos y fuentes en el wiki del proyecto.",
323
- "Construye base de conocimiento persistente entre sesiones de trabajo.",
324
- "/wiki-ingest",
325
- ),
326
- "wiki-query": (
327
- "Responde preguntas usando el wiki como base de conocimiento.",
328
- "El agente recuerda decisiones, convenciones y contexto del proyecto.",
329
- "/wiki-query",
330
- ),
331
- "wiki-lint": (
332
- "Verifica integridad del wiki: índice, links, páginas huérfanas.",
333
- "Mantiene el wiki saludable y navegable con auto-reparación.",
334
- "/wiki-lint",
335
- ),
336
- }
337
-
338
- # (descripción, agentes que provee)
339
- _PROFILE_INFO: dict[str, tuple[str, str]] = {
340
- "hono-drizzle": ("API TypeScript con Hono + Drizzle ORM.", "api-engineer"),
341
- "nextjs-admin": ("Panel admin con Next.js + React.", "admin-engineer"),
342
- "astro": ("Sitios estáticos/híbridos con Astro.", "frontend-engineer"),
343
- "vuenuxt": ("Aplicaciones Vue 3 / Nuxt 3.", "frontend-engineer"),
344
- "sveltekit": ("Aplicaciones SvelteKit.", "frontend-engineer"),
345
- "fastapi": ("API Python con FastAPI + Pydantic.", "api-engineer"),
346
- "django": ("Full-stack Python con Django.", "api-engineer"),
347
- "rails": ("Full-stack Ruby on Rails.", "api-engineer"),
348
- "express": ("API Node.js con Express.", "api-engineer"),
349
- "nestjs": ("API Node.js modular con NestJS.", "api-engineer"),
350
- "go-gin": ("API Go de alta performance con Gin.", "api-engineer"),
351
- "laravel": (
352
- "PHP con Laravel — API Sanctum, Blade+Livewire y migraciones L6→L13.",
353
- "api-engineer · fullstack-engineer · migration-specialist",
354
- ),
355
- "wordpress": (
356
- "WordPress moderno: FSE, Gutenberg, Divi y Elementor.",
357
- "wp-engineer · divi-engineer · elementor-engineer",
358
- ),
359
- "expo": ("Apps móviles con Expo / React Native.", "mobile-engineer"),
360
- "playwright-crawler": ("Web scraping y crawling con Playwright.", "scanner-engineer"),
361
- }
362
-
363
- _PROFILE_RELEVANCE: dict[str, list[dict]] = {
364
- "hono-drizzle": [{"backend": {"hono"}}, {"language": {"typescript"}}],
365
- "nextjs-admin": [{"frontend": {"nextjs"}}],
366
- "astro": [{"frontend": {"astro"}}],
367
- "vuenuxt": [{"frontend": {"nuxt"}}],
368
- "sveltekit": [{"frontend": {"sveltekit"}}],
369
- "fastapi": [{"backend": {"fastapi"}}, {"language": {"python"}}],
370
- "django": [{"backend": {"django"}}, {"language": {"python"}}],
371
- "rails": [{"backend": {"rails"}}, {"language": {"ruby"}}],
372
- "express": [{"backend": {"express"}}],
373
- "nestjs": [{"backend": {"nestjs"}}],
374
- "go-gin": [{"backend": {"gin"}}, {"language": {"go"}}],
375
- "laravel": [{"backend": {"laravel"}}, {"language": {"php"}}],
376
- "wordpress": [{"type": {"wordpress"}}, {"language": {"php"}}],
377
- "expo": [{"type": {"mobile"}}],
378
- "playwright-crawler": [{"type": {"crawler"}}],
379
- }
380
-
381
-
382
- def _profile_is_relevant(profile_name: str, config: dict) -> bool:
383
- """Retorna True si el profile coincide con el stack del proyecto."""
384
- conditions = _PROFILE_RELEVANCE.get(profile_name)
385
- if not conditions:
386
- return True # profile sin mapeo conocido → siempre mostrar
387
-
388
- stack = config.get("stack", {}) or {}
389
- project = config.get("project", {}) or {}
390
- values = {
391
- "backend": str(stack.get("backend") or "").lower(),
392
- "frontend": str(stack.get("frontend") or "").lower(),
393
- "language": str(project.get("language") or "").lower(),
394
- "type": str(project.get("type") or "").lower(),
395
- }
396
-
397
- for cond in conditions:
398
- if all(values.get(field, "") in allowed for field, allowed in cond.items()):
399
- return True
400
- return False
401
-
402
-
403
- # ── Oportunidades ─────────────────────────────────────────────────────────────
404
-
405
- def find_opportunities(forge, config, installed_names):
406
- opps = []
407
- agents_cfg = config.get("agents", {})
408
- active_profiles = agents_cfg.get("profiles", [])
409
- skills_cfg = config.get("skills", {})
410
- active_skills = skills_cfg.get("active", [])
411
- integrations = skills_cfg.get("integrations", [])
412
-
413
- # Profiles disponibles en forge que el proyecto no usa Y son relevantes para su stack
414
- profiles_dir = forge / "profiles"
415
- has_stack_info = bool(
416
- (config.get("stack", {}) or {}).get("backend") or
417
- (config.get("stack", {}) or {}).get("frontend") or
418
- (config.get("project", {}) or {}).get("language") or
419
- (config.get("project", {}) or {}).get("type")
420
- )
421
-
422
- if profiles_dir.exists():
423
- for profile_dir in sorted(profiles_dir.iterdir()):
424
- if not profile_dir.is_dir():
425
- continue
426
- profile_name = profile_dir.name
427
- if profile_name in active_profiles:
428
- continue
429
- # Filtrar por relevancia solo si el proyecto declara info de stack
430
- if has_stack_info and not _profile_is_relevant(profile_name, config):
431
- continue
432
- agents_in_profile = list((profile_dir / "agents").glob("*.md"))
433
- if agents_in_profile:
434
- agent_names = [a.stem for a in agents_in_profile]
435
- opps.append({
436
- "type": "profile",
437
- "slug": profile_name,
438
- "msg": f"Profile '{profile_name}' disponible en forge → provee: {', '.join(agent_names)}",
439
- "fix": f"Agregar '{profile_name}' a agents.profiles en project.yaml"
440
- })
441
-
442
- # Skills universales disponibles que no están activos
443
- skills_dir = forge / "core" / "skills"
444
- universal_skills = {
445
- "security-audit", "db-migrate", "local2prod", "new-feature",
446
- "browser-test", "wiki-ingest", "wiki-query", "wiki-lint",
447
- }
448
- for skill in sorted(universal_skills - set(active_skills)):
449
- skill_path = skills_dir / skill / "SKILL.md"
450
- if skill_path.exists():
451
- opps.append({
452
- "type": "skill",
453
- "slug": skill,
454
- "msg": f"Skill '{skill}' disponible pero no activo en project.yaml",
455
- "fix": f"Agregar '{skill}' a skills.active en project.yaml"
456
- })
457
-
458
- # Wiki health — si los skills están activos, verificar que el wiki existe
459
- wiki_skills_active = {"wiki-ingest", "wiki-query", "wiki-lint"}.intersection(active_skills)
460
- if wiki_skills_active:
461
- root = config.get("_root")
462
- wiki_cfg = config.get("wiki", {})
463
- wiki_path_str = wiki_cfg.get("path", "docs/wiki") if wiki_cfg else "docs/wiki"
464
- if root:
465
- wiki_path = Path(root) / wiki_path_str
466
- if not wiki_path.exists():
467
- opps.append({
468
- "type": "wiki",
469
- "msg": f"Skills de wiki activos pero {wiki_path_str}/ no existe",
470
- "fix": "python3 .agentic/scripts/forge-init.py --tool claude-code (crea la estructura inicial)"
471
- })
472
- elif not (wiki_path / "index.md").exists():
473
- opps.append({
474
- "type": "wiki",
475
- "msg": f"{wiki_path_str}/index.md falta — wiki incompleto",
476
- "fix": "python3 .agentic/scripts/forge-init.py --tool claude-code (recrea el índice)"
477
- })
478
-
479
- # Integración obsidian disponible
480
- if "obsidian-sync" not in integrations:
481
- opps.append({
482
- "type": "integration",
483
- "msg": "Skill 'obsidian-sync' disponible (requiere Obsidian + Local REST API)",
484
- "fix": "Agregar 'obsidian-sync' a skills.integrations si usás Obsidian"
485
- })
486
-
487
- # deploy.provider no configurado
488
- deploy = config.get("deploy", {})
489
- if not deploy.get("provider"):
490
- opps.append({
491
- "type": "config",
492
- "msg": "deploy.provider no configurado — skill 'local2prod' no puede funcionar sin él",
493
- "fix": "Configurar deploy.provider en project.yaml (vercel | railway | fly | github-actions | custom)"
494
- })
495
-
496
- return opps
497
-
498
-
499
- # ── Report ────────────────────────────────────────────────────────────────────
500
-
501
- def level_icon(level: str) -> str:
502
- return {"ok": OK("✓"), "warn": WARN("⚠"), "error": ERR("✗"), "info": INFO("→")}.get(level, " ")
503
-
504
-
505
- _EMPTY_SUMMARY = {
506
- "agents_total": 0, "agents_declared": 0,
507
- "ok": 0, "info": 0, "warnings": 0, "errors": 0, "orphans": 0,
508
- }
509
-
510
-
511
- def run_audit(as_json: bool = False, forge_override=None, only=None):
512
- try:
513
- root = find_project_root()
514
- if forge_override is not None:
515
- forge = Path(forge_override)
516
- if not forge.exists():
517
- if as_json:
518
- print(json.dumps({"error": f"forge dir no existe: {forge_override}",
519
- "error_code": "FORGE_NOT_FOUND",
520
- "summary": {**_EMPTY_SUMMARY, "errors": 1}}))
521
- sys.exit(0)
522
- print(ERR(f"ERROR: forge dir no existe: {forge}"), file=sys.stderr)
523
- sys.exit(1)
524
- else:
525
- forge = find_forge_dir()
526
- config = load_project(root)
527
- config["_root"] = str(root) # pass root for wiki path resolution
528
- except FileNotFoundError as e:
529
- if as_json:
530
- code = "NO_PROJECT_YAML" if "project.yaml" in str(e) else "FORGE_NOT_FOUND"
531
- print(json.dumps({
532
- "error": str(e),
533
- "error_code": code,
534
- "hint": "Ejecuta el wizard: python3 .agentic/scripts/forge-wizard.py" if code == "NO_PROJECT_YAML" else "",
535
- "summary": {**_EMPTY_SUMMARY, "errors": 1},
536
- }))
537
- sys.exit(0)
538
- print(ERR(f"ERROR: {e}"), file=sys.stderr)
539
- print(ERR(" → Primer uso: python3 .agentic/scripts/forge-wizard.py"), file=sys.stderr)
540
- sys.exit(1)
541
-
542
- agents_dir = root / ".claude" / "agents"
543
- agents_cfg = config.get("agents", {})
544
- active_profiles = agents_cfg.get("profiles", [])
545
-
546
- declared = set(
547
- agents_cfg.get("active", []) +
548
- agents_cfg.get("compliance", []) +
549
- agents_cfg.get("specialized", []) +
550
- # agentes que los profiles proveerían
551
- [p.stem
552
- for profile in active_profiles
553
- for p in (forge / "profiles" / profile / "agents").glob("*.md")
554
- if (forge / "profiles" / profile / "agents").exists()]
555
- )
556
-
557
- # Leer todos los agentes instalados
558
- installed = {}
559
- if agents_dir.exists():
560
- for f in sorted(agents_dir.glob("*.md")):
561
- installed[f.stem] = read_agent(f)
562
-
563
- # Filtrar por --only
564
- if only:
565
- if only not in installed:
566
- print(ERR(f"ERROR: agente '{only}' no encontrado en .claude/agents/"), file=sys.stderr)
567
- sys.exit(1)
568
- installed = {only: installed[only]}
569
-
570
- # ── Audit por agente ──────────────────────────────────────────────────────
571
- agent_results = {}
572
- for name, agent in installed.items():
573
- checks = []
574
- checks += check_frontmatter(agent)
575
- checks += check_sections(agent)
576
- checks += check_vs_forge(agent, forge, active_profiles)
577
-
578
- tier = agent["frontmatter"].get("tier", "?")
579
- in_declared = name in declared
580
- if not in_declared:
581
- checks.append({"level": "warn", "msg": "No declarado en project.yaml (active/compliance/profiles/specialized)"})
582
-
583
- worst = "ok"
584
- for c_ in checks:
585
- if c_["level"] == "error":
586
- worst = "error"
587
- break
588
- if c_["level"] == "warn" and worst != "error":
589
- worst = "warn"
590
- if c_["level"] == "info" and worst == "ok":
591
- worst = "info"
592
-
593
- agent_results[name] = {"tier": tier, "checks": checks, "status": worst}
594
-
595
- # ── Oportunidades ─────────────────────────────────────────────────────────
596
- opps = find_opportunities(forge, config, set(installed.keys()))
597
-
598
- # ── Huérfanos ─────────────────────────────────────────────────────────────
599
- orphans = [n for n in installed if n not in declared]
600
-
601
- # ── Output ────────────────────────────────────────────────────────────────
602
- proj_name = config.get("project", {}).get("name", "?")
603
- n_ok = sum(1 for r in agent_results.values() if r["status"] == "ok")
604
- n_info = sum(1 for r in agent_results.values() if r["status"] == "info")
605
- n_warn = sum(1 for r in agent_results.values() if r["status"] == "warn")
606
- n_err = sum(1 for r in agent_results.values() if r["status"] == "error")
607
-
608
- if as_json:
609
- print(json.dumps({
610
- "project": config.get("project", {}).get("name"),
611
- "summary": {
612
- "agents_total": len(installed),
613
- "agents_declared": len(declared),
614
- "ok": n_ok,
615
- "info": n_info,
616
- "warnings": n_warn,
617
- "errors": n_err,
618
- "orphans": len(orphans),
619
- },
620
- "agents": {k: {**v, "path": str(v.get("path", ""))} for k, v in agent_results.items()},
621
- "opportunities": opps,
622
- "orphans": orphans,
623
- }, indent=2, default=str))
624
- return
625
-
626
- print()
627
- print(BOLD(f"forge audit — {proj_name}"))
628
- print("═" * 60)
629
-
630
- # Resumen
631
- print(f"\n{BOLD('RESUMEN')}")
632
- print(f" {len(installed)} agentes en .claude/agents/")
633
- print(f" {len(declared)} declarados en project.yaml")
634
- print(f" {OK(str(n_ok))} conformes {INFO(str(n_info))} sugerencias {WARN(str(n_warn))} advertencias {ERR(str(n_err))} gaps")
635
- if orphans:
636
- print(f" {WARN(str(len(orphans)))} huérfanos (en .claude/ pero no en project.yaml)")
637
-
638
- # Agentes por tier
639
- by_tier: dict[str, list] = {}
640
- for name, result in agent_results.items():
641
- t = str(result["tier"])
642
- by_tier.setdefault(t, []).append((name, result))
643
-
644
- tier_labels = {"1": "Tier 1 — universal (core)", "2": "Tier 2 — profile (stack-specific)", "3": "Tier 3 — dominio (proyecto)", "?": "Sin tier declarado"}
645
- print(f"\n{BOLD('SALUD POR AGENTE')}")
646
- print("─" * 60)
647
-
648
- for tier_key in sorted(by_tier.keys()):
649
- print(f"\n {DIM(tier_labels.get(tier_key, f'Tier {tier_key}'))}")
650
- ok_names = [n for n, r in sorted(by_tier[tier_key]) if r["status"] == "ok"]
651
- prob_items = [(n, r) for n, r in sorted(by_tier[tier_key]) if r["status"] != "ok"]
652
- if ok_names:
653
- print(f" {OK('✓')} {DIM(' · '.join(ok_names))}")
654
- for name, result in prob_items:
655
- icon = level_icon(result["status"])
656
- print(f" {icon} {BOLD(name)}")
657
- for check in result["checks"]:
658
- if check["level"] == "ok":
659
- continue
660
- sub_icon = level_icon(check["level"])
661
- print(f" {sub_icon} {check['msg']}")
662
- if "fix" in check:
663
- print(f" {DIM('→ ' + check['fix'])}")
664
-
665
- # Oportunidades
666
- selectable = [o for o in opps if o.get("type") in ("profile", "skill") and o.get("slug")]
667
- non_selectable = [o for o in opps if o not in selectable]
668
- items_indexed: list[dict] = []
669
-
670
- if opps:
671
- can_pick = bool(selectable) and sys.stdout.isatty() and not as_json and not only
672
-
673
- if can_pick:
674
- # Sugerencias no-seleccionables: mostrar antes del TUI
675
- if non_selectable:
676
- print(f"\n{BOLD('OPORTUNIDADES')} {DIM(f'({len(opps)} disponibles)')}")
677
- print("─" * 60)
678
- print(f"\n {DIM('─── Otras sugerencias ───────────────────────────────────')}")
679
- type_labels_ns = {"integration": "Integración", "config": "Config", "wiki": "Wiki"}
680
- for opp in non_selectable:
681
- label = DIM(f"[{type_labels_ns.get(opp['type'], opp['type'])}]")
682
- print(f" {INFO('→')} {label} {opp['msg']}")
683
- if "fix" in opp:
684
- print(f" {DIM('→ ' + opp['fix'])}")
685
- items_indexed = selectable
686
- _two_panel_opp_picker(items_indexed, root)
687
- else:
688
- # Modo estático — CI, JSON, --only, no-TTY
689
- print(f"\n{BOLD('OPORTUNIDADES')} {DIM(f'({len(opps)} disponibles)')}")
690
- print("─" * 60)
691
-
692
- profile_opps = [o for o in selectable if o["type"] == "profile"]
693
- skill_opps = [o for o in selectable if o["type"] == "skill"]
694
-
695
- def _wrap(text: str) -> str:
696
- lines = _textwrap.wrap(text, width=66)
697
- return ("\n" + " " * 7).join(lines)
698
-
699
- if profile_opps:
700
- print(f"\n {DIM('─── Profiles de stack ───────────────────────────────────')}")
701
- for o in profile_opps:
702
- idx = len(items_indexed) + 1
703
- items_indexed.append(o)
704
- slug = o["slug"]
705
- info = _PROFILE_INFO.get(slug)
706
- desc = info[0] if info else ""
707
- agents_l = info[1] if info else (o.get("msg", "").split("→ provee:")[-1].strip())
708
- tag = DIM("[Profile]")
709
- print(f"\n {BOLD(f'[{idx}]')} {BOLD(slug):<28} {tag}")
710
- if desc:
711
- print(f" {_wrap(desc)}")
712
- if agents_l:
713
- print(f" {DIM('Agentes: ' + agents_l)}")
714
-
715
- if skill_opps:
716
- print(f"\n {DIM('─── Skills disponibles ──────────────────────────────────')}")
717
- for o in skill_opps:
718
- idx = len(items_indexed) + 1
719
- items_indexed.append(o)
720
- slug = o["slug"]
721
- info = _SKILL_INFO.get(slug)
722
- what = info[0] if info else o.get("msg", "")
723
- benefit = info[1] if info else ""
724
- trigger = info[2] if info else ""
725
- tag = DIM(f"[Skill {trigger}]") if trigger else DIM("[Skill]")
726
- print(f"\n {BOLD(f'[{idx}]')} {BOLD(slug):<28} {tag}")
727
- print(f" {_wrap(what)}")
728
- if benefit:
729
- print(f" {DIM(_wrap(benefit))}")
730
-
731
- if non_selectable:
732
- print(f"\n {DIM('─── Otras sugerencias ───────────────────────────────────')}")
733
- type_labels_ns = {"integration": "Integración", "config": "Config", "wiki": "Wiki"}
734
- for opp in non_selectable:
735
- label = DIM(f"[{type_labels_ns.get(opp['type'], opp['type'])}]")
736
- print(f" {INFO('→')} {label} {opp['msg']}")
737
- if "fix" in opp:
738
- print(f" {DIM('→ ' + opp['fix'])}")
739
-
740
- # Huérfanos
741
- if orphans:
742
- print(f"\n{BOLD('HUÉRFANOS')} {DIM('(en .claude/ pero no en project.yaml)')}")
743
- print("─" * 60)
744
- for name in orphans:
745
- print(f" {WARN('⚠')} {name}.md — agregar a agents.active, profiles o specialized")
746
-
747
- # Gaps de project.yaml
748
- missing_in_project = [n for n in declared if n not in installed]
749
- if missing_in_project:
750
- print(f"\n{BOLD('AGENTES DECLARADOS SIN ARCHIVO')}")
751
- print("─" * 60)
752
- for name in missing_in_project:
753
- print(f" {ERR('✗')} {name}.md — declarado en project.yaml pero no existe en .claude/agents/")
754
- forge_path, source = get_forge_version(forge, name, active_profiles)
755
- if forge_path:
756
- print(f" {DIM(f'→ Disponible en forge ({source}): python3 .agentic/scripts/forge-init.py --tool claude-code')}")
757
- else:
758
- print(f" {DIM('→ Crear manualmente en .claude/agents/')}")
759
-
760
- print()
761
- if n_err > 0:
762
- print(ERR(f" {n_err} gap(s) requieren atención antes de usar este proyecto con forge."))
763
- elif n_warn > 0:
764
- print(WARN(f" {n_warn} advertencia(s). El proyecto funciona pero puede mejorar."))
765
- else:
766
- print(OK(" Todo conforme al estándar forge."))
767
- print(DIM(" Nota: similitud < 0.80 puede indicar personalización intencional, no solo desactualización."))
768
- print()
769
-
770
-
771
-
772
- def _simple_opp_picker(items_indexed: list, root: Path) -> None:
773
- """Fallback picker lineal cuando no hay termios (Windows / no-TTY)."""
774
- total = len(items_indexed)
775
- hint = f"1-{total}" if total > 1 else "1"
776
- print(f" {DIM(f'Seleccioná [{hint}], separados por coma · a=todos · Enter=saltear')}")
777
- try:
778
- sys.stdout.write(" > ")
779
- sys.stdout.flush()
780
- raw = input().strip()
781
- except (EOFError, KeyboardInterrupt):
782
- print()
783
- return
784
- if not raw or raw.lower() in ("n", ""):
785
- return
786
- if raw.lower() == "a":
787
- selected = items_indexed
788
- else:
789
- selected = []
790
- for part in raw.split(","):
791
- part = part.strip()
792
- if part.isdigit():
793
- idx = int(part)
794
- if 1 <= idx <= len(items_indexed):
795
- selected.append(items_indexed[idx - 1])
796
- if not selected:
797
- print(DIM(" Sin cambios."))
798
- return
799
- profiles_to_add = [o["slug"] for o in selected if o["type"] == "profile"]
800
- skills_to_add = [o["slug"] for o in selected if o["type"] == "skill"]
801
- _apply_to_project_yaml(root, profiles_to_add, skills_to_add)
802
-
803
-
804
- def _two_panel_opp_picker(items_indexed: list, root: Path) -> None:
805
- """Picker TUI de dos paneles: lista izquierda | detalle derecho."""
806
- import shutil as _shutil
807
-
808
- if not (sys.stdin.isatty() and sys.stdout.isatty()):
809
- _simple_opp_picker(items_indexed, root)
810
- return
811
- try:
812
- import termios as _termios
813
- import tty as _tty
814
- except ImportError:
815
- _simple_opp_picker(items_indexed, root)
816
- return
817
-
818
- cols = _shutil.get_terminal_size(fallback=(80, 24)).columns
819
- if cols < 60:
820
- _simple_opp_picker(items_indexed, root)
821
- return
822
-
823
- # Dimensiones
824
- left_inner = min(36, max(28, (cols - 3) * 4 // 10))
825
- right_inner = cols - left_inner - 5 # │ + sp + │ + sp + left border
826
- panel_rows = min(len(items_indexed) + 1, max(6, 14))
827
-
828
- # Estado
829
- selected: set[int] = set()
830
- cursor = 0
831
-
832
- _HIDE = "\033[?25l"
833
- _SHOW = "\033[?25h"
834
- _CY = "\033[36m"
835
- _BD = "\033[1m"
836
- _DM = "\033[2m"
837
- _GN = "\033[32m"
838
- _RST = "\033[0m"
839
-
840
- def _vlen(s: str) -> int:
841
- return len(re.sub(r'\033\[[0-9;]*m', '', s))
842
-
843
- def _pad(s: str, w: int) -> str:
844
- v = _vlen(s)
845
- if v > w:
846
- plain = re.sub(r'\033\[[0-9;]*m', '', s)
847
- return plain[:w - 1] + "…"
848
- return s + " " * (w - v)
849
-
850
- def _detail(item: dict) -> list:
851
- lines: list[str] = []
852
- itype = item.get("type", "")
853
- slug = item.get("slug", "")
854
- w = right_inner - 2
855
- if itype == "skill":
856
- info = _SKILL_INFO.get(slug)
857
- if info:
858
- what, benefit, trigger = info
859
- lines.append(f"{_DM}Skill {trigger}{_RST}")
860
- lines.append("")
861
- lines += [f" {ln}" for ln in _textwrap.wrap(what, w - 2)]
862
- lines.append("")
863
- lines.append(f"{_DM}Qué ganás:{_RST}")
864
- lines += [f" {ln}" for ln in _textwrap.wrap(benefit, w - 2)]
865
- else:
866
- lines += _textwrap.wrap(item.get("msg", ""), w)
867
- elif itype == "profile":
868
- info = _PROFILE_INFO.get(slug)
869
- if info:
870
- desc, agents_l = info
871
- lines.append(f"{_DM}Profile{_RST}")
872
- lines.append("")
873
- lines += _textwrap.wrap(desc, w)
874
- lines.append("")
875
- lines.append(f"{_DM}Agentes:{_RST}")
876
- for a in agents_l.split(" · "):
877
- lines.append(f" · {a.strip()}")
878
- else:
879
- lines += _textwrap.wrap(item.get("msg", ""), w)
880
- return lines
881
-
882
- def _draw() -> None:
883
- sys.stdout.write("\033[2J\033[H")
884
-
885
- n_sel = len(selected)
886
- n_prof = sum(1 for o in items_indexed if o.get("type") == "profile")
887
- n_skill = sum(1 for o in items_indexed if o.get("type") == "skill")
888
- hdr = f"Oportunidades — {n_prof} profile(s) · {n_skill} skill(s)"
889
- sys.stdout.write(f"\n {_BD}{hdr}{_RST}\n\n")
890
-
891
- l_border = "─" * left_inner
892
- r_border = "─" * right_inner
893
- cur_item = items_indexed[cursor]
894
- cur_slug = cur_item.get("slug", "?")
895
- sys.stdout.write(f" ╭{l_border}╮ ╭─ {_BD}{_pad(cur_slug, right_inner - 4)}{_RST} ─╮\n")
896
-
897
- vis_start = max(0, cursor - panel_rows // 2)
898
- vis_start = min(vis_start, max(0, len(items_indexed) - panel_rows))
899
- detail = (_detail(cur_item) + [""] * panel_rows)[:panel_rows]
900
-
901
- for row in range(panel_rows):
902
- item_idx = vis_start + row
903
- if item_idx < len(items_indexed):
904
- item = items_indexed[item_idx]
905
- slug = item.get("slug", "")
906
- itype = item.get("type", "")
907
- tag = "PRF" if itype == "profile" else "SKL"
908
- chk = f"{_GN}☑{_RST}" if item_idx in selected else "☐"
909
- if item_idx == cursor:
910
- left_raw = f"{_CY}❯{_RST} {chk} {_DM}{tag}{_RST} {_BD}{slug}{_RST}"
911
- else:
912
- left_raw = f" {chk} {_DM}{tag}{_RST} {slug}"
913
- else:
914
- left_raw = ""
915
-
916
- right_raw = detail[row] if row < len(detail) else ""
917
- sys.stdout.write(f" │{_pad(left_raw, left_inner)}│ │{_pad(right_raw, right_inner)}│\n")
918
-
919
- sys.stdout.write(f" ╰{l_border}╯ ╰{r_border}╯\n")
920
-
921
- sel_slugs = [items_indexed[i].get("slug", "") for i in sorted(selected)]
922
- sel_display = ", ".join(sel_slugs) if sel_slugs else "ninguno"
923
- sel_label = f"{n_sel} seleccionado(s)" if n_sel else "Ninguno seleccionado"
924
- sys.stdout.write(f"\n {_DM}{sel_label}:{_RST} {_DM}{sel_display}{_RST}\n")
925
- sys.stdout.write(f" {_DM}↑↓ navegar Space seleccionar a todos Enter aplicar q saltear{_RST}\n")
926
- sys.stdout.flush()
927
-
928
- def _getch() -> str:
929
- fd = sys.stdin.fileno()
930
- old = _termios.tcgetattr(fd)
931
- try:
932
- _tty.setraw(fd)
933
- ch = sys.stdin.read(1)
934
- if ch == "\x1b":
935
- ch2 = sys.stdin.read(1)
936
- ch3 = sys.stdin.read(1)
937
- return f"\x1b{ch2}{ch3}"
938
- return ch
939
- finally:
940
- _termios.tcsetattr(fd, _termios.TCSADRAIN, old)
941
-
942
- sys.stdout.write(_HIDE)
943
- confirmed = False
944
- try:
945
- while True:
946
- _draw()
947
- ch = _getch()
948
- if ch in ("\x03", "q"):
949
- break
950
- elif ch == "\x1b[A":
951
- cursor = max(0, cursor - 1)
952
- elif ch == "\x1b[B":
953
- cursor = min(len(items_indexed) - 1, cursor + 1)
954
- elif ch == " ":
955
- if cursor in selected:
956
- selected.discard(cursor)
957
- else:
958
- selected.add(cursor)
959
- elif ch.lower() == "a":
960
- if len(selected) == len(items_indexed):
961
- selected.clear()
962
- else:
963
- selected = set(range(len(items_indexed)))
964
- elif ch in ("\r", "\n"):
965
- confirmed = True
966
- break
967
- finally:
968
- sys.stdout.write(_SHOW)
969
- sys.stdout.write("\033[2J\033[H")
970
- sys.stdout.flush()
971
-
972
- if confirmed and selected:
973
- sel_items = [items_indexed[i] for i in sorted(selected)]
974
- profiles_to_add = [o["slug"] for o in sel_items if o["type"] == "profile"]
975
- skills_to_add = [o["slug"] for o in sel_items if o["type"] == "skill"]
976
- _apply_to_project_yaml(root, profiles_to_add, skills_to_add)
977
- else:
978
- print(DIM(" Sin cambios."))
979
-
980
-
981
- def _apply_to_project_yaml(root: Path, profiles: list[str], skills: list[str]) -> None:
982
- """Agrega profiles y skills a project.yaml de forma segura."""
983
- yaml_path = root / "project.yaml"
984
- if not yaml_path.exists():
985
- print(ERR(" ERROR: project.yaml no encontrado."))
986
- return
987
-
988
- try:
989
- with open(yaml_path) as f:
990
- content = yaml.safe_load(f)
991
- except Exception as e:
992
- print(ERR(f" ERROR al leer project.yaml: {e}"))
993
- return
994
-
995
- changed = False
996
-
997
- if profiles:
998
- agents = content.setdefault("agents", {})
999
- current_profiles = agents.get("profiles", []) or []
1000
- added = []
1001
- for p in profiles:
1002
- if p not in current_profiles:
1003
- current_profiles.append(p)
1004
- added.append(p)
1005
- agents["profiles"] = current_profiles
1006
- if added:
1007
- print(OK(f" Profiles agregados: {', '.join(added)}"))
1008
- changed = True
1009
-
1010
- if skills:
1011
- skill_cfg = content.setdefault("skills", {})
1012
- current_skills = skill_cfg.get("active", []) or []
1013
- added = []
1014
- for s in skills:
1015
- if s not in current_skills:
1016
- current_skills.append(s)
1017
- added.append(s)
1018
- skill_cfg["active"] = current_skills
1019
- if added:
1020
- print(OK(f" Skills agregados: {', '.join(added)}"))
1021
- changed = True
1022
-
1023
- if not changed:
1024
- print(DIM(" Sin cambios (ya estaban configurados)."))
1025
- return
1026
-
1027
- try:
1028
- with open(yaml_path, "w") as f:
1029
- yaml.dump(content, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
1030
- print(OK(f" project.yaml actualizado."))
1031
- except Exception as e:
1032
- print(ERR(f" ERROR al escribir project.yaml: {e}"))
1033
- return
1034
-
1035
- print()
1036
- print(DIM(" Para instalar los agentes:"))
1037
- print(DIM(" python3 .agentic/scripts/forge-init.py --tool claude-code"))
1038
- print()
1039
-
1040
-
1041
- def main():
1042
- as_json = "--json" in sys.argv
1043
- forge_override = None
1044
- only = None
1045
- args = sys.argv[1:]
1046
- i = 0
1047
- while i < len(args):
1048
- arg = args[i]
1049
- if arg.startswith("--forge="):
1050
- forge_override = arg[len("--forge="):]
1051
- elif arg == "--forge" and i + 1 < len(args):
1052
- i += 1
1053
- forge_override = args[i]
1054
- elif arg.startswith("--only="):
1055
- only = arg[len("--only="):]
1056
- i += 1
1057
- run_audit(as_json=as_json, forge_override=forge_override, only=only)
1058
-
1059
-
1060
- if __name__ == "__main__":
1061
- main()