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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/documentation-writer/SKILL.md +45 -0
  3. data/.agents/skills/gem-release/SKILL.md +116 -0
  4. data/.agents/skills/quality-code/SKILL.md +51 -0
  5. data/.agents/skills/sentry/SKILL.md +135 -0
  6. data/.agents/skills/sentry/references/api-endpoints.md +147 -0
  7. data/.agents/skills/sentry/scripts/sentry.rb +194 -0
  8. data/.agents/skills/skill-builder/SKILL.md +293 -0
  9. data/.agents/skills/skill-manager/SKILL.md +225 -0
  10. data/.agents/skills/skill-manager/scripts/sync.rb +356 -0
  11. data/.agents/skills/yard/SKILL.md +311 -0
  12. data/.agents/skills/yard/references/tipos.md +144 -0
  13. data/CHANGELOG.md +14 -0
  14. data/CLAUDE.md +28 -225
  15. data/README.md +5 -3
  16. data/lib/bug_bunny/consumer.rb +21 -5
  17. data/lib/bug_bunny/otel.rb +47 -0
  18. data/lib/bug_bunny/producer.rb +13 -4
  19. data/lib/bug_bunny/request.rb +14 -2
  20. data/lib/bug_bunny/version.rb +1 -1
  21. data/lib/bug_bunny.rb +1 -0
  22. data/skill/SKILL.md +253 -0
  23. data/skill/references/client-middleware.md +161 -0
  24. data/skill/references/consumer.md +122 -0
  25. data/skill/references/controller.md +105 -0
  26. data/skill/references/errores.md +97 -0
  27. data/skill/references/resource.md +116 -0
  28. data/skill/references/routing.md +82 -0
  29. data/skill/references/testing.md +138 -0
  30. data/skills.lock +30 -0
  31. data/skills.yml +40 -0
  32. data/spec/integration/consumer_middleware_spec.rb +23 -2
  33. data/spec/unit/consumer_spec.rb +138 -6
  34. data/spec/unit/otel_spec.rb +54 -0
  35. data/spec/unit/producer_spec.rb +187 -0
  36. data/spec/unit/request_spec.rb +51 -0
  37. metadata +28 -29
  38. data/.agents/skills/rabbitmq-expert/SKILL.md +0 -1555
  39. data/.claude/commands/gem-ai-setup.md +0 -174
  40. data/.claude/commands/pr.md +0 -53
  41. data/.claude/commands/release.md +0 -52
  42. data/.claude/commands/rubocop.md +0 -22
  43. data/.claude/commands/service-ai-setup.md +0 -168
  44. data/.claude/commands/test.md +0 -28
  45. data/.claude/commands/yard.md +0 -46
  46. data/docs/_index.md +0 -50
  47. data/docs/ai/_index.md +0 -56
  48. data/docs/ai/antipatterns.md +0 -166
  49. data/docs/ai/api.md +0 -251
  50. data/docs/ai/architecture.md +0 -92
  51. data/docs/ai/errors.md +0 -158
  52. data/docs/ai/faq_external.md +0 -133
  53. data/docs/ai/faq_internal.md +0 -86
  54. data/docs/ai/glossary.md +0 -45
  55. data/docs/concepts.md +0 -140
  56. data/docs/howto/controller.md +0 -194
  57. data/docs/howto/middleware_client.md +0 -119
  58. data/docs/howto/middleware_consumer.md +0 -127
  59. data/docs/howto/rails.md +0 -214
  60. data/docs/howto/resource.md +0 -200
  61. data/docs/howto/routing.md +0 -133
  62. data/docs/howto/testing.md +0 -259
  63. 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