bug_bunny 4.8.0 → 4.9.0
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.
- checksums.yaml +4 -4
- data/.agents/skills/documentation-writer/SKILL.md +45 -0
- data/.agents/skills/gem-release/SKILL.md +116 -0
- data/.agents/skills/quality-code/SKILL.md +51 -0
- data/.agents/skills/sentry/SKILL.md +135 -0
- data/.agents/skills/sentry/references/api-endpoints.md +147 -0
- data/.agents/skills/sentry/scripts/sentry.rb +194 -0
- data/.agents/skills/skill-builder/SKILL.md +293 -0
- data/.agents/skills/skill-manager/SKILL.md +225 -0
- data/.agents/skills/skill-manager/scripts/sync.rb +356 -0
- data/.agents/skills/yard/SKILL.md +311 -0
- data/.agents/skills/yard/references/tipos.md +144 -0
- data/CHANGELOG.md +14 -0
- data/CLAUDE.md +28 -225
- data/README.md +5 -3
- data/lib/bug_bunny/consumer.rb +21 -5
- data/lib/bug_bunny/otel.rb +47 -0
- data/lib/bug_bunny/producer.rb +13 -4
- data/lib/bug_bunny/request.rb +14 -2
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +1 -0
- data/skill/SKILL.md +253 -0
- data/skill/references/client-middleware.md +161 -0
- data/skill/references/consumer.md +122 -0
- data/skill/references/controller.md +105 -0
- data/skill/references/errores.md +97 -0
- data/skill/references/resource.md +116 -0
- data/skill/references/routing.md +82 -0
- data/skill/references/testing.md +138 -0
- data/skills.lock +30 -0
- data/skills.yml +40 -0
- data/spec/integration/consumer_middleware_spec.rb +23 -2
- data/spec/unit/consumer_spec.rb +138 -6
- data/spec/unit/otel_spec.rb +54 -0
- data/spec/unit/producer_spec.rb +187 -0
- data/spec/unit/request_spec.rb +51 -0
- metadata +28 -29
- data/.agents/skills/rabbitmq-expert/SKILL.md +0 -1555
- data/.claude/commands/gem-ai-setup.md +0 -174
- data/.claude/commands/pr.md +0 -53
- data/.claude/commands/release.md +0 -52
- data/.claude/commands/rubocop.md +0 -22
- data/.claude/commands/service-ai-setup.md +0 -168
- data/.claude/commands/test.md +0 -28
- data/.claude/commands/yard.md +0 -46
- data/docs/_index.md +0 -50
- data/docs/ai/_index.md +0 -56
- data/docs/ai/antipatterns.md +0 -166
- data/docs/ai/api.md +0 -251
- data/docs/ai/architecture.md +0 -92
- data/docs/ai/errors.md +0 -158
- data/docs/ai/faq_external.md +0 -133
- data/docs/ai/faq_internal.md +0 -86
- data/docs/ai/glossary.md +0 -45
- data/docs/concepts.md +0 -140
- data/docs/howto/controller.md +0 -194
- data/docs/howto/middleware_client.md +0 -119
- data/docs/howto/middleware_consumer.md +0 -127
- data/docs/howto/rails.md +0 -214
- data/docs/howto/resource.md +0 -200
- data/docs/howto/routing.md +0 -133
- data/docs/howto/testing.md +0 -259
- data/docs/howto/tracing.md +0 -119
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-manager
|
|
3
|
+
description: Configura, valida y sincroniza la infraestructura de skills en cualquier proyecto Ruby (gema o servicio). Úsala para inicializar `skills.yml`, verificar la skill del proyecto (`skill/`), sincronizar skills de dependencias a `.agents/skills/`, o validar que todo el estándar se cumpla. Requiere `skill-builder`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill Manager
|
|
7
|
+
|
|
8
|
+
Skill unificada que gestiona toda la infraestructura de skills de un proyecto Ruby, sea gema o microservicio. Detecta automáticamente el tipo de proyecto y actúa en consecuencia.
|
|
9
|
+
|
|
10
|
+
## Detección de tipo de proyecto
|
|
11
|
+
|
|
12
|
+
- **Gema**: existe `.gemspec` en la raíz.
|
|
13
|
+
- **Servicio**: existe `config/application.rb`.
|
|
14
|
+
- Si ambos existen, priorizar gema.
|
|
15
|
+
|
|
16
|
+
## Requisitos
|
|
17
|
+
- La skill `skill-builder` debe estar disponible para generar `skill/SKILL.md`.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Flujo de Trabajo (Modo Update)
|
|
22
|
+
|
|
23
|
+
### Paso 1 — Determinar escenario de complejidad de la skill
|
|
24
|
+
Analizá el proyecto para determinar qué estructura de `skill/` corresponde:
|
|
25
|
+
|
|
26
|
+
- **Escenario 1 (simple):** Proyecto pequeño → solo `skill/SKILL.md`.
|
|
27
|
+
- **Escenario 2 (con referencias):** API extensa o catálogo de errores grande → `SKILL.md` + `references/`.
|
|
28
|
+
- **Escenario 3 (con scripts):** Necesita herramientas de diagnóstico, migración o validación → `SKILL.md` + `scripts/`.
|
|
29
|
+
- **Escenario 4 (completa):** Combina referencias y scripts → `SKILL.md` + `references/` + `scripts/`.
|
|
30
|
+
|
|
31
|
+
Si `skill/` ya existe, evaluá si el escenario debe escalar.
|
|
32
|
+
|
|
33
|
+
### Paso 2 — Generar o actualizar la skill del proyecto
|
|
34
|
+
Ejecutá `skill-builder` para generar o actualizar `skill/`. El skill-builder detecta automáticamente si es gema o servicio y analiza el código correspondiente.
|
|
35
|
+
|
|
36
|
+
### Paso 3 — Gemspec (solo gemas)
|
|
37
|
+
Si el proyecto es una gema, verificá que el `.gemspec` cumpla:
|
|
38
|
+
|
|
39
|
+
1. **`metadata["documentation_uri"]`** apuntando a la carpeta de skill.
|
|
40
|
+
- Si falta, inferí la URL desde `homepage_uri` o `source_code_uri`:
|
|
41
|
+
`spec.metadata["documentation_uri"] = "https://github.com/[ORG]/[GEM_NAME]/blob/v#{spec.version}/skill"`
|
|
42
|
+
|
|
43
|
+
2. **`spec.files` incluye `skill/`** para que la skill se empaquete dentro de la gema.
|
|
44
|
+
- Si usa `git ls-files`: verificá que `skill/` no esté en `.gitignore`.
|
|
45
|
+
- Si usa un glob explícito: asegurate de que incluya `skill/**/*`.
|
|
46
|
+
|
|
47
|
+
- Mostrá el diff y pedí confirmación antes de escribir.
|
|
48
|
+
|
|
49
|
+
### Paso 4 — Configurar skills.yml
|
|
50
|
+
Si no existe `skills.yml` en la raíz, crealo detectando dependencias en el `Gemfile` y repos conocidos.
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
# skills.yml — Manifiesto único de skills del proyecto
|
|
54
|
+
|
|
55
|
+
# --- MCPs requeridos ---
|
|
56
|
+
# Declara qué MCPs necesita el proyecto.
|
|
57
|
+
|
|
58
|
+
mcps:
|
|
59
|
+
- github
|
|
60
|
+
- clickup
|
|
61
|
+
|
|
62
|
+
# --- Gemas ---
|
|
63
|
+
# Array de nombres. El sync busca skill/ en cada gema instalada.
|
|
64
|
+
|
|
65
|
+
gems:
|
|
66
|
+
- mi_gema
|
|
67
|
+
- otra_gema
|
|
68
|
+
|
|
69
|
+
# --- Servicios ---
|
|
70
|
+
# Hash { nombre => config }. Descarga skill/ del repo remoto.
|
|
71
|
+
|
|
72
|
+
services:
|
|
73
|
+
mi_servicio:
|
|
74
|
+
repo: wispro/mi_servicio
|
|
75
|
+
|
|
76
|
+
# --- Skills ---
|
|
77
|
+
# Hash { nombre => config }. Formato estilo docker-compose.
|
|
78
|
+
# Cada skill es una clave con su configuración.
|
|
79
|
+
#
|
|
80
|
+
# Claves disponibles:
|
|
81
|
+
# - repo (requerido): org/repo de GitHub
|
|
82
|
+
# - scope (opcional): global | local (default: local)
|
|
83
|
+
# - path (opcional): path custom en el repo (default: skills/[nombre])
|
|
84
|
+
# - environment (opcional): configuración específica de la skill
|
|
85
|
+
|
|
86
|
+
skills:
|
|
87
|
+
skill-manager:
|
|
88
|
+
repo: sequre/ai_knowledge
|
|
89
|
+
scope: global
|
|
90
|
+
quality-code:
|
|
91
|
+
repo: sequre/ai_knowledge
|
|
92
|
+
scope: global
|
|
93
|
+
gem-release:
|
|
94
|
+
repo: sequre/ai_knowledge
|
|
95
|
+
service-release:
|
|
96
|
+
repo: sequre/ai_knowledge
|
|
97
|
+
skill-builder:
|
|
98
|
+
repo: sequre/ai_knowledge
|
|
99
|
+
yard:
|
|
100
|
+
repo: sequre/ai_knowledge
|
|
101
|
+
sentry:
|
|
102
|
+
repo: sequre/ai_knowledge
|
|
103
|
+
environment:
|
|
104
|
+
url: "https://sentry.cloud.wispro.co"
|
|
105
|
+
org: "wispro"
|
|
106
|
+
projects:
|
|
107
|
+
- billing-api
|
|
108
|
+
- billing-workers
|
|
109
|
+
agent-review:
|
|
110
|
+
repo: sequre/ai_knowledge
|
|
111
|
+
environment:
|
|
112
|
+
space_id: "90144913465"
|
|
113
|
+
list_id: "901415149921"
|
|
114
|
+
ai-reports:
|
|
115
|
+
repo: sequre/ai_knowledge
|
|
116
|
+
environment:
|
|
117
|
+
space_id: "90144913465"
|
|
118
|
+
bug_reports_list_id: "901415148810"
|
|
119
|
+
improvements_list_id: "901415148812"
|
|
120
|
+
# Skills externas con path custom
|
|
121
|
+
documentation-writer:
|
|
122
|
+
repo: github/awesome-copilot
|
|
123
|
+
path: skills/documentation-writer
|
|
124
|
+
rabbitmq-expert:
|
|
125
|
+
repo: martinholovsky/claude-skills-generator
|
|
126
|
+
path: skills/rabbitmq-expert
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Variables de entorno en skills.yml
|
|
130
|
+
El parser expande `${VAR}` con el valor de la variable de entorno. Útil para tokens o IDs sensibles:
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
skills:
|
|
134
|
+
ai-reports:
|
|
135
|
+
repo: sequre/ai_knowledge
|
|
136
|
+
environment:
|
|
137
|
+
space_id: "${CLICKUP_SPACE_ID}"
|
|
138
|
+
bug_reports_list_id: "${CLICKUP_BUG_REPORTS_LIST}"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
*Mostrá diff y pedí confirmación.*
|
|
142
|
+
|
|
143
|
+
### Paso 5 — Configurar sincronización
|
|
144
|
+
Asegurate de que el script de sync esté configurado para ejecutarse:
|
|
145
|
+
- Agregá al final de `bin/setup`:
|
|
146
|
+
```bash
|
|
147
|
+
ruby .agents/skills/skill-manager/scripts/sync.rb
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Paso 6 — Git
|
|
152
|
+
- Agregá `.agents/skills/` completo al `.gitignore`:
|
|
153
|
+
```gitignore
|
|
154
|
+
# Skills locales (descargadas por sync + propias del dev)
|
|
155
|
+
.agents/skills/
|
|
156
|
+
```
|
|
157
|
+
- `.agents/skills/` es siempre local y no se commitea. Contiene skills descargadas por el sync y opcionalmente skills privadas del dev.
|
|
158
|
+
- Si una skill debe ser compartida, se declara en `skills.yml` y se distribuye via sync.
|
|
159
|
+
|
|
160
|
+
### Paso 7 — CLAUDE.md
|
|
161
|
+
El bloque "Knowledge Base" debe estar al **tope absoluto** del archivo:
|
|
162
|
+
```markdown
|
|
163
|
+
## Knowledge Base
|
|
164
|
+
- **Mandato Crítico:** Las skills en `.agents/skills/` incluyen conocimiento de dependencias.
|
|
165
|
+
- **Protocolo de Consulta:** El agente DEBE leer la skill de una dependencia antes de responder sobre ella.
|
|
166
|
+
- **Rebuild:** `ruby .agents/skills/skill-manager/scripts/sync.rb
|
|
167
|
+
` actualiza las skills de dependencias.
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Script de sincronización
|
|
173
|
+
|
|
174
|
+
El script `scripts/sync.rb` lee `skills.yml` y sincroniza todas las skills de dependencias a `.agents/skills/`.
|
|
175
|
+
|
|
176
|
+
| Sección | Fuente | Mecanismo |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `gems` | Gema Ruby instalada | Copia local desde `gem_dir/skill/` |
|
|
179
|
+
| `services` | Repo de microservicio | GitHub API → `skill/` del repo |
|
|
180
|
+
| `skills` | Repo GitHub | GitHub API → path configurable (default: `skills/[name]/`) |
|
|
181
|
+
|
|
182
|
+
### Ejecución directa
|
|
183
|
+
```bash
|
|
184
|
+
ruby .agents/skills/skill-manager/scripts/sync.rb
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Requisitos del script
|
|
189
|
+
- Ruby (stdlib — sin dependencias externas)
|
|
190
|
+
- `gh` CLI o `GITHUB_TOKEN` en el entorno (para repos privados)
|
|
191
|
+
- `skills.yml` en la raíz del proyecto
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## MCP de GitHub (opcional)
|
|
196
|
+
|
|
197
|
+
Si tenés un MCP de GitHub disponible, el agente puede usarlo para:
|
|
198
|
+
- **Inspeccionar repos** sin depender de `GITHUB_TOKEN` en el entorno.
|
|
199
|
+
- **Detectar estructura de skills** en dependencias.
|
|
200
|
+
- **Armar `skills.yml` inicial**: explorar repos del `Gemfile` y detectar cuáles tienen skill.
|
|
201
|
+
- **Verificar cambios**: comparar la skill local con la remota antes de actualizar.
|
|
202
|
+
|
|
203
|
+
Si el MCP no está disponible, se ejecuta el script de sync como fallback.
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Modos de Uso
|
|
208
|
+
|
|
209
|
+
### /skill-manager check
|
|
210
|
+
Valida el cumplimiento del estándar sin modificar archivos:
|
|
211
|
+
1. Verificá que exista `skill/SKILL.md`.
|
|
212
|
+
2. Verificá consistencia: todo archivo en `references/` y `scripts/` debe estar referenciado en `SKILL.md`, y viceversa.
|
|
213
|
+
3. **(Solo gemas)** Verificá `documentation_uri` y que `spec.files` incluya `skill/`.
|
|
214
|
+
4. Verificá existencia de `skills.yml`.
|
|
215
|
+
5. Verificá `.gitignore` y `bin/setup`.
|
|
216
|
+
6. Reportá: OK o lista de errores con pasos para resolverlos.
|
|
217
|
+
|
|
218
|
+
### /skill-manager update
|
|
219
|
+
Ejecuta el flujo de trabajo completo (Pasos 1-7). Configura toda la infraestructura sin tocar contenido existente de skills.
|
|
220
|
+
|
|
221
|
+
### /skill-manager sync
|
|
222
|
+
Actualiza las skills de dependencias en `.agents/skills/`:
|
|
223
|
+
1. Si hay MCP de GitHub disponible, usalo para inspeccionar repos y descargar skills.
|
|
224
|
+
2. Si no hay MCP, ejecutá el script `scripts/sync.rb`.
|
|
225
|
+
3. Reportá qué skills se actualizaron, cuáles son nuevas y cuáles no tienen skill.
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'net/http'
|
|
8
|
+
require 'uri'
|
|
9
|
+
|
|
10
|
+
module SkillsSync
|
|
11
|
+
SKILLS_YML = 'skills.yml'
|
|
12
|
+
SKILLS_LOCK = 'skills.lock'
|
|
13
|
+
SKILLS_DIR = File.join('.agents', 'skills')
|
|
14
|
+
|
|
15
|
+
GLOBAL_SKILL_PATHS = [
|
|
16
|
+
File.join(Dir.home, '.agents', 'skills'),
|
|
17
|
+
File.join(Dir.home, '.claude', '.agents', 'skills'),
|
|
18
|
+
File.join(Dir.home, '.claude', 'skills')
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def run
|
|
23
|
+
config_path = File.join(Dir.pwd, SKILLS_YML)
|
|
24
|
+
|
|
25
|
+
unless File.exist?(config_path)
|
|
26
|
+
puts "skill-manager sync — ERROR: #{SKILLS_YML} no encontrado en #{Dir.pwd}"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
config = load_config(config_path)
|
|
31
|
+
@local_skills_path = File.join(Dir.pwd, SKILLS_DIR)
|
|
32
|
+
FileUtils.mkdir_p(@local_skills_path)
|
|
33
|
+
|
|
34
|
+
@use_gh = !gh_available?.nil?
|
|
35
|
+
@token = ENV['GITHUB_TOKEN']
|
|
36
|
+
|
|
37
|
+
unless @use_gh || @token
|
|
38
|
+
puts "skill-manager sync — WARNING: ni 'gh' CLI ni GITHUB_TOKEN disponibles. Solo se sincronizarán gemas locales."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@previous_lock = load_lock
|
|
42
|
+
@current_lock = []
|
|
43
|
+
|
|
44
|
+
sync_gems(config['gems'] || [])
|
|
45
|
+
sync_services(config['services'] || [])
|
|
46
|
+
sync_skills(config['skills'] || [])
|
|
47
|
+
|
|
48
|
+
cleanup_removed_skills
|
|
49
|
+
save_lock
|
|
50
|
+
|
|
51
|
+
puts 'skill-manager sync — OK.'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# --- Config parsing ---
|
|
57
|
+
|
|
58
|
+
# Lee skills.yml y expande variables de entorno ${VAR}
|
|
59
|
+
def load_config(path)
|
|
60
|
+
content = File.read(path)
|
|
61
|
+
content = content.gsub(/\$\{(\w+)\}/) { ENV[Regexp.last_match(1)].to_s }
|
|
62
|
+
YAML.safe_load(content)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# --- Lock file ---
|
|
66
|
+
|
|
67
|
+
def load_lock
|
|
68
|
+
lock_path = File.join(Dir.pwd, SKILLS_LOCK)
|
|
69
|
+
return [] unless File.exist?(lock_path)
|
|
70
|
+
|
|
71
|
+
data = YAML.safe_load_file(lock_path)
|
|
72
|
+
data['skills'] || []
|
|
73
|
+
rescue StandardError
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def save_lock
|
|
78
|
+
lock_path = File.join(Dir.pwd, SKILLS_LOCK)
|
|
79
|
+
data = {
|
|
80
|
+
'synced_at' => Time.now.strftime('%Y-%m-%d %H:%M:%S'),
|
|
81
|
+
'skills' => @current_lock.sort_by { |s| s['name'] }
|
|
82
|
+
}
|
|
83
|
+
File.write(lock_path, YAML.dump(data))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def record_lock(name, scope, path)
|
|
87
|
+
@current_lock << { 'name' => name, 'scope' => scope || 'local', 'path' => path }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cleanup_removed_skills
|
|
91
|
+
previous_names = @previous_lock.map { |s| s['name'] }
|
|
92
|
+
current_names = @current_lock.map { |s| s['name'] }
|
|
93
|
+
removed = previous_names - current_names
|
|
94
|
+
|
|
95
|
+
removed.each do |name|
|
|
96
|
+
entry = @previous_lock.find { |s| s['name'] == name }
|
|
97
|
+
path = entry['path']
|
|
98
|
+
|
|
99
|
+
if File.exist?(path)
|
|
100
|
+
puts " #{name} — eliminado (ya no está en #{SKILLS_YML})"
|
|
101
|
+
FileUtils.rm_rf(path)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# --- Scope resolution ---
|
|
107
|
+
|
|
108
|
+
# Retorna un hash { dest:, install: } donde install indica si hay que descargar.
|
|
109
|
+
# Si install es false (porque ya existe globalmente), dest contiene el path global
|
|
110
|
+
# para que igualmente se registre en el lock y no se borre en cleanup.
|
|
111
|
+
def resolve_dest(name, scope)
|
|
112
|
+
case scope
|
|
113
|
+
when 'global'
|
|
114
|
+
global_path = find_global_path(name) || default_global_path
|
|
115
|
+
dest = File.join(global_path, name)
|
|
116
|
+
local_path = File.join(@local_skills_path, name)
|
|
117
|
+
|
|
118
|
+
# Si existe local, borrarla
|
|
119
|
+
if File.exist?(local_path)
|
|
120
|
+
puts " #{name} — eliminando copia local (scope: global)"
|
|
121
|
+
FileUtils.rm_rf(local_path)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
{ dest: dest, install: true }
|
|
125
|
+
else # local o sin especificar
|
|
126
|
+
# Si existe global, saltear la instalación pero registrar en el lock
|
|
127
|
+
existing_global = find_global_path(name)
|
|
128
|
+
if existing_global
|
|
129
|
+
global_dest = File.join(existing_global, name)
|
|
130
|
+
puts " #{name} — disponible globalmente en #{global_dest}. Saltando."
|
|
131
|
+
return { dest: global_dest, install: false }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
{ dest: File.join(@local_skills_path, name), install: true }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def find_global_path(name)
|
|
139
|
+
GLOBAL_SKILL_PATHS.find do |path|
|
|
140
|
+
File.exist?(File.join(path, name, 'SKILL.md'))
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def default_global_path
|
|
145
|
+
existing = GLOBAL_SKILL_PATHS.find { |p| File.directory?(p) }
|
|
146
|
+
return existing if existing
|
|
147
|
+
|
|
148
|
+
path = GLOBAL_SKILL_PATHS.first
|
|
149
|
+
FileUtils.mkdir_p(path)
|
|
150
|
+
path
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# --- Sync methods ---
|
|
154
|
+
|
|
155
|
+
# Nuevo formato: gems es un array de strings (nombres de gemas)
|
|
156
|
+
def sync_gems(gems)
|
|
157
|
+
return unless gems.is_a?(Array)
|
|
158
|
+
|
|
159
|
+
gems.each do |name|
|
|
160
|
+
resolution = resolve_dest(name, 'local')
|
|
161
|
+
next unless resolution
|
|
162
|
+
|
|
163
|
+
unless resolution[:install]
|
|
164
|
+
record_lock(name, 'local', resolution[:dest])
|
|
165
|
+
next
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
begin
|
|
169
|
+
spec = Gem::Specification.find_by_name(name)
|
|
170
|
+
rescue Gem::MissingSpecError
|
|
171
|
+
puts " WARNING: gema '#{name}' no instalada. Saltando."
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
skill_dir = File.join(spec.gem_dir, 'skill')
|
|
176
|
+
skill_file = File.join(skill_dir, 'SKILL.md')
|
|
177
|
+
|
|
178
|
+
unless File.exist?(skill_file)
|
|
179
|
+
puts " WARNING: #{name} v#{spec.version} no incluye skill en skill/. Saltando."
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
puts " #{name} v#{spec.version} (local)"
|
|
184
|
+
replace_dir(resolution[:dest])
|
|
185
|
+
FileUtils.cp_r(Dir[File.join(skill_dir, '*')], resolution[:dest])
|
|
186
|
+
record_lock(name, 'local', resolution[:dest])
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Nuevo formato: services es un Hash { nombre => config }
|
|
191
|
+
# Cada servicio tiene: repo (requerido), scope (opcional)
|
|
192
|
+
def sync_services(services)
|
|
193
|
+
return unless services.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
services.each do |name, service_config|
|
|
196
|
+
service_config ||= {}
|
|
197
|
+
scope = service_config['scope']
|
|
198
|
+
resolution = resolve_dest(name, scope)
|
|
199
|
+
next unless resolution
|
|
200
|
+
|
|
201
|
+
unless resolution[:install]
|
|
202
|
+
record_lock(name, scope, resolution[:dest])
|
|
203
|
+
next
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
repo = service_config['repo']
|
|
207
|
+
unless repo
|
|
208
|
+
puts " WARNING: service '#{name}' no tiene 'repo' definido. Saltando."
|
|
209
|
+
next
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
scope_label = scope == 'global' ? 'global' : 'local'
|
|
213
|
+
puts " #{name} (GitHub: #{repo}, skill/) [#{scope_label}]"
|
|
214
|
+
replace_dir(resolution[:dest])
|
|
215
|
+
download_github_dir(repo, 'main', 'skill', resolution[:dest])
|
|
216
|
+
record_lock(name, scope, resolution[:dest])
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Nuevo formato: skills es un Hash { nombre => config }
|
|
221
|
+
# Cada skill tiene: repo (requerido), scope (opcional), path (opcional), environment (opcional)
|
|
222
|
+
def sync_skills(skills)
|
|
223
|
+
return unless skills.is_a?(Hash)
|
|
224
|
+
|
|
225
|
+
skills.each do |name, skill_config|
|
|
226
|
+
skill_config ||= {}
|
|
227
|
+
scope = skill_config['scope']
|
|
228
|
+
resolution = resolve_dest(name, scope)
|
|
229
|
+
next unless resolution
|
|
230
|
+
|
|
231
|
+
unless resolution[:install]
|
|
232
|
+
record_lock(name, scope, resolution[:dest])
|
|
233
|
+
next
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
repo = skill_config['repo']
|
|
237
|
+
unless repo
|
|
238
|
+
puts " WARNING: skill '#{name}' no tiene 'repo' definido. Saltando."
|
|
239
|
+
next
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
remote_path = skill_config['path'] || "skills/#{name}"
|
|
243
|
+
scope_label = scope == 'global' ? 'global' : 'local'
|
|
244
|
+
puts " #{name} (GitHub: #{repo}, #{remote_path}/) [#{scope_label}]"
|
|
245
|
+
replace_dir(resolution[:dest])
|
|
246
|
+
download_github_dir(repo, 'main', remote_path, resolution[:dest])
|
|
247
|
+
record_lock(name, scope, resolution[:dest])
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# --- Helpers ---
|
|
252
|
+
|
|
253
|
+
def replace_dir(dest)
|
|
254
|
+
FileUtils.rm_rf(dest)
|
|
255
|
+
FileUtils.mkdir_p(dest)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def download_github_dir(repo, ref, remote_path, dest)
|
|
259
|
+
entries = list_github_dir(repo, ref, remote_path)
|
|
260
|
+
|
|
261
|
+
if entries.empty?
|
|
262
|
+
puts " WARNING: #{remote_path}/ no encontrado en #{repo}@#{ref}. Saltando."
|
|
263
|
+
return
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
entries.each do |entry|
|
|
267
|
+
next unless entry['type'] == 'file'
|
|
268
|
+
|
|
269
|
+
relative_path = entry['path'].sub(%r{^#{Regexp.escape(remote_path)}/}, '')
|
|
270
|
+
file_dest = File.join(dest, relative_path)
|
|
271
|
+
FileUtils.mkdir_p(File.dirname(file_dest))
|
|
272
|
+
|
|
273
|
+
content = if @use_gh
|
|
274
|
+
gh_fetch_file(repo, ref, entry['path'])
|
|
275
|
+
else
|
|
276
|
+
fetch_url(entry['download_url'])
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
if content
|
|
280
|
+
File.write(file_dest, content)
|
|
281
|
+
else
|
|
282
|
+
puts " WARNING: no se pudo descargar #{entry['path']} de #{repo}."
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def list_github_dir(repo, ref, path)
|
|
288
|
+
response = if @use_gh
|
|
289
|
+
gh_api("repos/#{repo}/contents/#{path}?ref=#{ref}")
|
|
290
|
+
else
|
|
291
|
+
fetch_url("https://api.github.com/repos/#{repo}/contents/#{path}?ref=#{ref}")
|
|
292
|
+
end
|
|
293
|
+
return [] unless response
|
|
294
|
+
|
|
295
|
+
items = JSON.parse(response)
|
|
296
|
+
return [] unless items.is_a?(Array)
|
|
297
|
+
|
|
298
|
+
all_entries = []
|
|
299
|
+
items.each do |item|
|
|
300
|
+
if item['type'] == 'dir'
|
|
301
|
+
all_entries.concat(list_github_dir(repo, ref, item['path']))
|
|
302
|
+
else
|
|
303
|
+
all_entries << item
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
all_entries
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# --- GitHub CLI ---
|
|
310
|
+
|
|
311
|
+
def gh_available?
|
|
312
|
+
`which gh 2>/dev/null`.strip
|
|
313
|
+
$?.success? ? true : nil
|
|
314
|
+
rescue StandardError
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def gh_api(endpoint)
|
|
319
|
+
output = `gh api "#{endpoint}" 2>/dev/null`
|
|
320
|
+
$?.success? ? output.force_encoding('UTF-8') : nil
|
|
321
|
+
rescue StandardError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def gh_fetch_file(repo, ref, path)
|
|
326
|
+
output = `gh api "repos/#{repo}/contents/#{path}?ref=#{ref}" --jq '.content' 2>/dev/null`
|
|
327
|
+
return nil unless $?.success?
|
|
328
|
+
|
|
329
|
+
require 'base64'
|
|
330
|
+
Base64.decode64(output).force_encoding('UTF-8')
|
|
331
|
+
rescue StandardError
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# --- HTTP directo ---
|
|
336
|
+
|
|
337
|
+
def fetch_url(url)
|
|
338
|
+
uri = URI.parse(url)
|
|
339
|
+
request = Net::HTTP::Get.new(uri)
|
|
340
|
+
request['Authorization'] = "Bearer #{@token}" if @token
|
|
341
|
+
request['User-Agent'] = 'skill-manager/1.0'
|
|
342
|
+
request['Accept'] = 'application/vnd.github.v3+json' if url.include?('api.github.com')
|
|
343
|
+
|
|
344
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
|
345
|
+
http.request(request)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
response.code == '200' ? response.body.force_encoding('UTF-8') : nil
|
|
349
|
+
rescue StandardError => e
|
|
350
|
+
puts " ERROR fetching #{url}: #{e.message}"
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
SkillsSync.run if __FILE__ == $PROGRAM_NAME
|