elsabro 3.8.2 → 4.0.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.
@@ -1,535 +1,562 @@
1
1
  #!/bin/bash
2
- # skill-discovery.sh - ELSABRO Hook para descubrir skills externos antes del planning
2
+ # skill-discovery.sh - ELSABRO Skill Discovery Hook (v4.0.0)
3
+ #
4
+ # Descubre, sugiere e instala skills usando vercel-labs/skills (npx skills)
5
+ # y escaneo local de ELSABRO skills.
3
6
  #
4
7
  # Uso: bash ./hooks/skill-discovery.sh "descripción de tarea" "complejidad"
5
- # Output: JSON con skills descubiertos, validados y rankeados
8
+ # Output: JSON con skills descubiertos, instalados y sugeridos
9
+ #
10
+ # Fuentes de descubrimiento:
11
+ # 1. Skills globales instalados (~/.claude/skills/)
12
+ # 2. Skills locales ELSABRO (./skills/*.md)
13
+ # 3. npx skills find <query> (vercel-labs/skills registry)
6
14
  #
7
- # Responsabilidades:
8
- # 1. Extraer keywords de la descripción
9
- # 2. Buscar skills en ELSABRO interno
10
- # 3. Buscar skills en marketplace externo (skills.sh)
11
- # 4. Validar y rankear resultados
12
- # 5. Retornar JSON con estructura normalizada
15
+ # Requiere: bash 4+, jq, node/npm (para npx)
13
16
 
14
- set -e
17
+ set -euo pipefail
15
18
 
16
19
  # ============================================================================
17
- # CONFIGURACIÓN Y CONSTANTES
20
+ # CONFIGURACIÓN
18
21
  # ============================================================================
19
22
 
20
- # Directorios
21
23
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
24
  PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
23
25
  ELSABRO_SKILLS_DIR="${PROJECT_ROOT}/skills"
24
- SKILLS_CACHE_FILE="${PROJECT_ROOT}/.cache/skills-discovery-cache.json"
25
- SKILLS_REGISTRY="${HOME}/.elsabro/skills-registry.json"
26
+ GLOBAL_SKILLS_DIR="${HOME}/.claude/skills"
27
+ CACHE_DIR="${PROJECT_ROOT}/.cache"
28
+ CACHE_FILE="${CACHE_DIR}/skill-discovery-cache.json"
29
+ CACHE_TTL=3600 # 1 hora
26
30
 
27
- # Configuración de descubrimiento
28
- KEYWORDS_MIN_MATCH=1
29
- MAX_SKILLS_RETURN=10
30
- EXTERNAL_API_TIMEOUT=15
31
- CACHE_TTL_SECONDS=3600
31
+ MAX_RESULTS=10
32
+ NPX_TIMEOUT=30 # segundos
32
33
 
33
- # Colores para stderr (feedback visual)
34
+ # Colores para stderr
34
35
  RED='\033[0;31m'
35
36
  GREEN='\033[0;32m'
36
37
  YELLOW='\033[1;33m'
37
38
  BLUE='\033[0;34m'
38
- CYAN='\033[0;36m'
39
39
  NC='\033[0m'
40
-
41
- # Prefijo de logs
42
- PREFIX="[ELSABRO:skill-discovery]"
40
+ PREFIX="[ELSABRO:skills]"
43
41
 
44
42
  # ============================================================================
45
- # UTILIDADES
43
+ # LOGGING (todo a stderr, stdout es solo JSON)
46
44
  # ============================================================================
47
45
 
48
- log_info() {
49
- echo -e "${BLUE}${PREFIX}${NC} $1" >&2
50
- }
51
-
52
- log_success() {
53
- echo -e "${GREEN}${PREFIX}${NC} ✓ $1" >&2
54
- }
46
+ log_info() { echo -e "${BLUE}${PREFIX}${NC} $1" >&2; }
47
+ log_success() { echo -e "${GREEN}${PREFIX}${NC} $1" >&2; }
48
+ log_warn() { echo -e "${YELLOW}${PREFIX}${NC} ⚠ $1" >&2; }
49
+ log_error() { echo -e "${RED}${PREFIX}${NC} ✗ $1" >&2; }
55
50
 
56
- log_warn() {
57
- echo -e "${YELLOW}${PREFIX}${NC} ⚠ $1" >&2
58
- }
51
+ # ============================================================================
52
+ # UTILIDADES
53
+ # ============================================================================
59
54
 
60
- log_error() {
61
- echo -e "${RED}${PREFIX}${NC} $1" >&2
55
+ ensure_dirs() {
56
+ mkdir -p "$CACHE_DIR" 2>/dev/null || true
57
+ mkdir -p "$GLOBAL_SKILLS_DIR" 2>/dev/null || true
62
58
  }
63
59
 
64
- # Crear directorios si no existen
65
- ensure_dirs() {
66
- mkdir -p "$(dirname "$SKILLS_CACHE_FILE")" 2>/dev/null || true
60
+ has_command() {
61
+ command -v "$1" &>/dev/null
67
62
  }
68
63
 
69
- # Verificar si caché es válido
64
+ # Verificar si caché es válido (no expirado)
70
65
  is_cache_valid() {
71
- local cache_file="$1"
72
-
73
- if [ ! -f "$cache_file" ]; then
66
+ local cache_key="$1"
67
+ if [ ! -f "$CACHE_FILE" ]; then
74
68
  return 1
75
69
  fi
76
-
77
- # Verificar edad del caché (en segundos)
78
- local file_age=$(($(date +%s) - $(stat -f%m "$cache_file" 2>/dev/null || echo 0)))
79
-
80
- if [ "$file_age" -gt "$CACHE_TTL_SECONDS" ]; then
70
+ if ! has_command jq; then
81
71
  return 1
82
72
  fi
73
+ local cached_at
74
+ cached_at=$(jq -r --arg key "$cache_key" '.[$key].cached_at // 0' "$CACHE_FILE" 2>/dev/null || echo "0")
75
+ local now
76
+ now=$(date +%s)
77
+ local age=$(( now - cached_at ))
78
+ [ "$age" -lt "$CACHE_TTL" ]
79
+ }
80
+
81
+ get_from_cache() {
82
+ local cache_key="$1"
83
+ jq --arg key "$cache_key" '.[$key].data' "$CACHE_FILE" 2>/dev/null
84
+ }
83
85
 
84
- return 0
86
+ save_to_cache() {
87
+ local cache_key="$1"
88
+ local data="$2"
89
+ ensure_dirs
90
+ local now
91
+ now=$(date +%s)
92
+ local tmp_file="${CACHE_FILE}.tmp.$$"
93
+ if [ -f "$CACHE_FILE" ] && jq empty "$CACHE_FILE" 2>/dev/null; then
94
+ jq --arg key "$cache_key" --argjson data "$data" --argjson ts "$now" \
95
+ '.[$key] = {"data": $data, "cached_at": $ts}' \
96
+ "$CACHE_FILE" > "$tmp_file" && mv "$tmp_file" "$CACHE_FILE"
97
+ else
98
+ jq -n --arg key "$cache_key" --argjson data "$data" --argjson ts "$now" \
99
+ '{($key): {"data": $data, "cached_at": $ts}}' > "$tmp_file" && mv "$tmp_file" "$CACHE_FILE"
100
+ fi
85
101
  }
86
102
 
87
103
  # ============================================================================
88
104
  # 1. EXTRACCIÓN DE KEYWORDS
89
105
  # ============================================================================
90
106
 
91
- # Extrae palabras clave de la descripción de tarea
92
- # Entrada: string (descripción)
93
- # Salida: JSON array con keywords encontradas
94
107
  extract_keywords() {
95
- local task_description="$1"
108
+ local task="$1"
109
+ local lower
110
+ lower=$(echo "$task" | tr '[:upper:]' '[:lower:]')
96
111
 
97
- # Convertir a lowercase para búsqueda insensible
98
- local lowercase=$(echo "$task_description" | tr '[:upper:]' '[:lower:]')
99
-
100
- # Definir patrones de keywords por categoría
101
- # Formato: categoria|sinónimo1|sinónimo2|sinónimo3
102
- local keyword_patterns=(
112
+ local -a keyword_patterns=(
103
113
  "api|api|backend|endpoint|rest|graphql|http|server"
104
- "auth|auth|login|signin|oauth|jwt|session|password|signup|credential"
105
- "db|database|db|postgres|postgresql|mysql|mongodb|firebase|sql|orm"
106
- "payments|payment|stripe|paypal|billing|checkout|commerce|transaction"
107
- "mobile|mobile|expo|react-native|ios|android|app|swift|kotlin"
108
- "web|website|web|nextjs|react|vue|angular|frontend|ui|ux|html|css"
109
- "devops|devops|docker|kubernetes|k8s|ci|cd|github|gitlab|deploy"
110
- "testing|test|jest|vitest|mocha|pytest|coverage|unit|e2e|qa"
111
- "security|security|encrypt|https|cors|ssl|tls|hash|salt|breach"
112
- "monitoring|monitor|sentry|logging|logs|error|analytics|observability"
113
- "cli|cli|command|tool|script|automation|terminal|bash|shell"
114
- "ai|ai|machine-learning|ml|gpt|claude|llm|nlp|transformer"
114
+ "auth|auth|login|signin|oauth|jwt|session|password|signup"
115
+ "db|database|db|postgres|mysql|mongodb|firebase|sql|orm|prisma|drizzle"
116
+ "payments|payment|stripe|paypal|billing|checkout|commerce"
117
+ "mobile|mobile|expo|react-native|ios|android|swift|kotlin|flutter"
118
+ "web|website|web|nextjs|react|vue|angular|frontend|ui|html|css|tailwind"
119
+ "devops|devops|docker|kubernetes|k8s|ci|cd|github|gitlab|deploy|deploying|deployment|vercel"
120
+ "testing|test|testing|jest|vitest|mocha|pytest|coverage|e2e|playwright"
121
+ "security|security|encrypt|https|cors|ssl|tls|vulnerability"
122
+ "monitoring|monitor|monitoring|sentry|logging|error|analytics|observability"
123
+ "cli|cli|command|tool|script|terminal|bash|shell"
124
+ "ai|ai|machine-learning|ml|gpt|claude|llm|nlp|openai|anthropic"
115
125
  )
116
126
 
117
- # Array para guardar keywords encontrados
118
- local found_keywords=()
119
- local found_categories=()
127
+ local -a found=()
120
128
 
121
- # Buscar cada patrón en la descripción
122
129
  for pattern in "${keyword_patterns[@]}"; do
123
- IFS='|' read -r category synmap <<< "$pattern"
124
-
125
- # Crear regex de sinónimos
126
- local regex=$(echo "$synmap" | sed 's/|/\\|/g')
130
+ local category="${pattern%%|*}"
131
+ local synonyms="${pattern#*|}"
127
132
 
128
- # Buscar con grep case-insensitive
129
- if echo "$lowercase" | grep -qiE "\b($regex)\b"; then
130
- found_keywords+=("\"$category\"")
131
- found_categories+=("$category")
133
+ # -E (extended regex) uses unescaped | for alternation
134
+ if echo "$lower" | grep -qiE "\b($synonyms)\b"; then
135
+ found+=("\"$category\"")
132
136
  fi
133
137
  done
134
138
 
135
- # Retornar como JSON array
136
- # Si no encontró keywords, retornar array vacío
137
- if [ ${#found_keywords[@]} -eq 0 ]; then
139
+ if [ ${#found[@]} -eq 0 ]; then
138
140
  echo "[]"
139
141
  else
140
- printf "["
141
- printf "%s" "${found_keywords[0]}"
142
- for kw in "${found_keywords[@]:1}"; do
143
- printf ",$kw"
142
+ local result="[${found[0]}"
143
+ for kw in "${found[@]:1}"; do
144
+ result+=",$kw"
144
145
  done
145
- printf "]\n"
146
+ result+="]"
147
+ echo "$result"
146
148
  fi
147
149
  }
148
150
 
149
151
  # ============================================================================
150
- # 2. DESCUBRIMIENTO DE SKILLS - FUENTE 1: ELSABRO INTERNO
152
+ # 2. ESCANEO DE SKILLS INSTALADOS GLOBALMENTE
151
153
  # ============================================================================
152
154
 
153
- # Busca skills en ./skills/*.md
154
- # Entrada: JSON array de keywords
155
- # Salida: JSON array con skills ELSABRO encontrados
156
- discover_elsabro_skills() {
157
- local keywords="$1"
158
-
159
- local skills_found=()
155
+ scan_installed_skills() {
156
+ local -a skills=()
160
157
 
161
- # Verificar que directorio existe
162
- if [ ! -d "$ELSABRO_SKILLS_DIR" ]; then
158
+ if [ ! -d "$GLOBAL_SKILLS_DIR" ]; then
163
159
  echo "[]"
164
160
  return
165
161
  fi
166
162
 
167
- # Iterar sobre cada archivo .md en skills/
168
- for skill_file in "$ELSABRO_SKILLS_DIR"/*.md; do
169
- [ -f "$skill_file" ] || continue
170
-
171
- local skill_name=$(basename "$skill_file" .md)
172
- local skill_content=$(cat "$skill_file")
163
+ # Buscar SKILL.md en subdirectorios de ~/.claude/skills/
164
+ for skill_dir in "$GLOBAL_SKILLS_DIR"/*/; do
165
+ [ -d "$skill_dir" ] || continue
166
+ local skill_name
167
+ skill_name=$(basename "$skill_dir")
168
+ local skill_file="${skill_dir}SKILL.md"
169
+
170
+ if [ -f "$skill_file" ]; then
171
+ local description=""
172
+ description=$(sed -n '/^[^#]/p' "$skill_file" | head -1 | cut -c1-100)
173
+
174
+ # Use jq for safe JSON construction (avoids injection from special chars)
175
+ local skill_obj
176
+ skill_obj=$(jq -n --arg id "$skill_name" --arg desc "$description" --arg loc "$skill_dir" \
177
+ '{id: $id, source: "installed", status: "installed", location: $loc, description: $desc}')
178
+ skills+=("$skill_obj")
179
+ fi
180
+ done
173
181
 
174
- # ---- Extraer metadata YAML ----
175
- # Buscar secciones YAML en el front matter
182
+ if [ ${#skills[@]} -eq 0 ]; then
183
+ echo "[]"
184
+ else
185
+ printf '%s\n' "${skills[@]}" | jq -s '.'
186
+ fi
187
+ }
176
188
 
177
- local description=$(
178
- echo "$skill_content" | sed -n '/^description:/s/^description: *//p' | head -1
179
- )
180
- description="${description:0:80}" # Limitar a 80 caracteres
189
+ # ============================================================================
190
+ # 3. ESCANEO DE SKILLS LOCALES ELSABRO
191
+ # ============================================================================
181
192
 
182
- local difficulty=$(
183
- echo "$skill_content" | sed -n '/^difficulty:/s/^difficulty: *//p' | head -1
184
- )
185
- difficulty="${difficulty:-intermediate}"
193
+ scan_elsabro_skills() {
194
+ local keywords_json="$1"
186
195
 
187
- local estimated_time=$(
188
- echo "$skill_content" | sed -n '/^estimated_time:/s/^estimated_time: *//p' | head -1
189
- )
196
+ if [ ! -d "$ELSABRO_SKILLS_DIR" ]; then
197
+ echo "[]"
198
+ return
199
+ fi
190
200
 
191
- # Extraer tags (múltiples líneas)
192
- local tags=$(
193
- echo "$skill_content" |
194
- sed -n '/^tags:/,/^[^ -]/p' |
195
- grep "^ - " |
196
- sed 's/^ - //' |
197
- head -10
198
- )
201
+ local -a skills=()
199
202
 
200
- # ---- Calcular match score ----
201
- # Score = cantidad de keywords que coinciden con tags/description
203
+ for skill_file in "$ELSABRO_SKILLS_DIR"/*.md; do
204
+ [ -f "$skill_file" ] || continue
202
205
 
203
- local match_score=0
206
+ local skill_name
207
+ skill_name=$(basename "$skill_file" .md)
204
208
 
205
- # Convertir keywords de JSON array a array bash
206
- local kw_array=()
207
- if command -v jq &> /dev/null; then
208
- while IFS= read -r kw; do
209
- kw_array+=("$kw")
210
- done < <(echo "$keywords" | jq -r '.[]' 2>/dev/null)
211
- fi
209
+ # Extraer metadata básica
210
+ local description=""
211
+ description=$(sed -n '/^description:/s/^description: *//p' "$skill_file" | head -1 | sed 's/"/\\"/g')
212
+ description="${description:0:100}"
212
213
 
213
- # Buscar keywords en tags y descripción
214
- for keyword in "${kw_array[@]}"; do
215
- if echo "$tags" | grep -qi "$keyword"; then
216
- ((match_score++))
217
- fi
218
- if echo "$description" | grep -qi "$keyword"; then
219
- ((match_score++))
220
- fi
221
- done
214
+ local tags=""
215
+ tags=$(sed -n '/^tags:/,/^[^ -]/p' "$skill_file" | grep "^ - " | sed 's/^ - //' | head -10 | tr '\n' ',' | sed 's/,$//')
222
216
 
223
- # Solo incluir si hay al menos un match
224
- if [ "$match_score" -ge "$KEYWORDS_MIN_MATCH" ]; then
225
- # Construir objeto skill JSON
226
- local tags_json=$(echo "$tags" | jq -R -s -c 'split("\n") | map(select(length > 0))')
227
-
228
- local skill_obj=$(cat <<EOF
229
- {
230
- "id": "$skill_name",
231
- "source": "elsabro",
232
- "status": "available",
233
- "match_score": $match_score,
234
- "description": "$description",
235
- "difficulty": "$difficulty",
236
- "estimated_time": "$estimated_time",
237
- "tags": $tags_json,
238
- "rank": 0
239
- }
240
- EOF
241
- )
217
+ # Calcular match score contra keywords
218
+ local match_score=0
219
+ if has_command jq; then
220
+ local kw_list
221
+ kw_list=$(echo "$keywords_json" | jq -r '.[]' 2>/dev/null)
222
+ for kw in $kw_list; do
223
+ if echo "$tags $description $skill_name" | grep -qi "$kw"; then
224
+ match_score=$((match_score + 1))
225
+ fi
226
+ done
227
+ fi
242
228
 
243
- skills_found+=("$skill_obj")
229
+ if [ "$match_score" -gt 0 ]; then
230
+ # Use jq for safe JSON construction
231
+ local skill_obj
232
+ skill_obj=$(jq -n --arg id "$skill_name" --arg desc "$description" --arg tags "$tags" --argjson score "$match_score" \
233
+ '{id: $id, source: "elsabro", status: "available", match_score: $score, description: $desc, tags: $tags}')
234
+ skills+=("$skill_obj")
244
235
  fi
245
236
  done
246
237
 
247
- # Retornar como JSON array
248
- if [ ${#skills_found[@]} -eq 0 ]; then
238
+ if [ ${#skills[@]} -eq 0 ]; then
249
239
  echo "[]"
250
240
  else
251
- # Usar jq para construir array válido
252
- printf '%s\n' "${skills_found[@]}" | jq -s '.'
241
+ printf '%s\n' "${skills[@]}" | jq -s 'sort_by(.match_score) | reverse'
253
242
  fi
254
243
  }
255
244
 
256
245
  # ============================================================================
257
- # 3. DESCUBRIMIENTO DE SKILLS - FUENTE 2: MARKETPLACE EXTERNO
246
+ # 4. DESCUBRIMIENTO EXTERNO VIA npx skills find
258
247
  # ============================================================================
259
248
 
260
- # Consulta API de skills.sh para encontrar skills externos
261
- # Entrada: JSON array de keywords
262
- # Salida: JSON array con skills de marketplace
263
- discover_external_skills() {
264
- local keywords="$1"
249
+ discover_via_npx_skills() {
250
+ local query="$1"
265
251
 
266
- # Si no está disponible curl o jq, retornar array vacío
267
- if ! command -v curl &> /dev/null || ! command -v jq &> /dev/null; then
268
- log_warn "curl o jq no disponible, saltando external skills"
252
+ if ! has_command npx; then
253
+ log_warn "npx no disponible, saltando descubrimiento externo"
269
254
  echo "[]"
270
255
  return
271
256
  fi
272
257
 
273
- # URL de la API
274
- local api_url="https://api.skills.sh/v1/discover"
258
+ log_info "Ejecutando: npx skills find \"$query\""
275
259
 
276
- # Payload: array de keywords
277
- local payload=$(echo "$keywords" | jq -c '.')
260
+ local raw_output=""
261
+ # Ejecutar npx skills find con timeout
262
+ raw_output=$(timeout "$NPX_TIMEOUT" npx -y skills find "$query" 2>/dev/null) || {
263
+ log_warn "npx skills find falló o timeout"
264
+ echo "[]"
265
+ return
266
+ }
278
267
 
279
- log_info "Consultando API: $api_url con keywords: $payload"
268
+ if [ -z "$raw_output" ]; then
269
+ echo "[]"
270
+ return
271
+ fi
280
272
 
281
- # Hacer petición HTTP con timeout
282
- local response=$(
283
- timeout "$EXTERNAL_API_TIMEOUT" curl -s \
284
- -X POST "$api_url" \
285
- -H "Content-Type: application/json" \
286
- -d "{\"keywords\": $payload}" \
287
- 2>/dev/null || echo "[]"
288
- )
273
+ # npx skills find retorna texto formateado, necesitamos parsearlo a JSON
274
+ # El formato típico es líneas con nombre y descripción
275
+ # Intentar parsear como JSON primero (por si devuelve JSON)
276
+ if echo "$raw_output" | jq empty 2>/dev/null; then
277
+ echo "$raw_output" | jq '[.[] | . + {"source": "skills-registry", "status": "available", "install_cmd": ("npx skills add -g " + .name)}]' 2>/dev/null || echo "[]"
278
+ return
279
+ fi
289
280
 
290
- # Procesar respuesta (asumir estructura: { "skills": [...] })
291
- if echo "$response" | jq empty 2>/dev/null; then
292
- # Extractar y enriquecer skills
293
- echo "$response" | jq -c '
294
- .skills[]? // empty |
295
- . + {
296
- status: "available",
297
- rank: 0,
298
- install_command: ("curl -s https://skills.sh/install/" + .id + " | bash")
299
- }
300
- ' | jq -s '.'
301
- else
302
- log_warn "API response inválido, retornando array vacío"
281
+ # Si no es JSON, parsear texto línea por línea
282
+ local -a skills=()
283
+ while IFS= read -r line; do
284
+ # Ignorar líneas vacías y headers
285
+ [ -z "$line" ] && continue
286
+ echo "$line" | grep -q "^─\|^━\|^=\|^Found\|^Searching\|^No " && continue
287
+
288
+ # Extraer nombre del skill (primera palabra que parece un nombre de skill)
289
+ local name
290
+ name=$(echo "$line" | grep -oE '[a-z][a-z0-9_-]+' | head -1)
291
+ [ -z "$name" ] && continue
292
+
293
+ local desc
294
+ desc=$(echo "$line" | sed "s/$name//" | sed 's/^[[:space:]-]*//' | cut -c1-100)
295
+
296
+ # Use jq for safe JSON construction
297
+ local skill_obj
298
+ skill_obj=$(jq -n --arg id "$name" --arg desc "$desc" --arg cmd "npx skills add -g $name" \
299
+ '{id: $id, source: "skills-registry", status: "available", description: $desc, install_cmd: $cmd}')
300
+ skills+=("$skill_obj")
301
+ done <<< "$raw_output"
302
+
303
+ if [ ${#skills[@]} -eq 0 ]; then
303
304
  echo "[]"
305
+ else
306
+ printf '%s\n' "${skills[@]}" | jq -s '.' 2>/dev/null || echo "[]"
304
307
  fi
305
308
  }
306
309
 
307
310
  # ============================================================================
308
- # 4. VALIDACIÓN Y RANKING
311
+ # 5. CHECK SKILLS ACTUALIZACIONES
309
312
  # ============================================================================
310
313
 
311
- # Valida y rankea todos los skills descubiertos
312
- # Entrada: JSON array de skills
313
- # Salida: JSON array validado y ordenado por rank
314
- validate_and_rank_skills() {
315
- local all_skills="$1"
314
+ check_skill_updates() {
315
+ if ! has_command npx; then
316
+ echo "[]"
317
+ return
318
+ fi
319
+
320
+ local check_output=""
321
+ check_output=$(timeout "$NPX_TIMEOUT" npx -y skills check 2>/dev/null) || {
322
+ echo "[]"
323
+ return
324
+ }
316
325
 
317
- if ! command -v jq &> /dev/null; then
318
- log_warn "jq no disponible, retornando skills sin validación"
319
- echo "$all_skills"
326
+ if [ -z "$check_output" ]; then
327
+ echo "[]"
320
328
  return
321
329
  fi
322
330
 
323
- # Validar y rankear con jq
324
- echo "$all_skills" | jq -c '
325
- # Filtrar skills válidos
326
- map(
327
- select(
328
- .id != null and
329
- .source != null and
330
- (.status == "available" or .status == "installable")
331
- )
332
- ) |
333
- # Agregar score de ranking
334
- map(
335
- . + {
336
- rank: (
337
- # Base: match_score
338
- (.match_score // 0) * 100 +
339
- # Bonus por dificultad (beginner > advanced)
340
- (if .difficulty == "beginner" then 50 else
341
- if .difficulty == "intermediate" then 30 else
342
- if .difficulty == "advanced" then 10 else 0 end end) +
343
- # Bonus por source (elsabro es más confiable)
344
- (if .source == "elsabro" then 20 else 0 end) +
345
- # Penalty por external que requiere instalación
346
- (if (.install_command != null) then 0 else 5 end)
347
- )
348
- }
349
- ) |
350
- # Ordenar por ranking descendente
351
- sort_by(.rank) | reverse |
352
- # Limitar a top 10
353
- .[0:10]
354
- '
331
+ # Si es JSON, retornar directamente
332
+ if echo "$check_output" | jq empty 2>/dev/null; then
333
+ echo "$check_output"
334
+ return
335
+ fi
336
+
337
+ # Parsear texto de updates disponibles
338
+ local -a updates=()
339
+ while IFS= read -r line; do
340
+ [ -z "$line" ] && continue
341
+ local name
342
+ name=$(echo "$line" | grep -oE '[a-z][a-z0-9_-]+' | head -1)
343
+ [ -z "$name" ] && continue
344
+ updates+=("{\"id\":\"$name\",\"update_available\":true,\"update_cmd\":\"npx skills update $name\"}")
345
+ done <<< "$check_output"
346
+
347
+ if [ ${#updates[@]} -eq 0 ]; then
348
+ echo "[]"
349
+ else
350
+ printf '%s\n' "${updates[@]}" | jq -s '.' 2>/dev/null || echo "[]"
351
+ fi
355
352
  }
356
353
 
357
354
  # ============================================================================
358
- # 5. CACHÉ
355
+ # 6. RANKING Y RECOMENDACIONES
359
356
  # ============================================================================
360
357
 
361
- # Intenta obtener skills del caché
362
- # Entrada: keywords (string)
363
- # Salida: JSON array con skills si caché válido, "null" si no
364
- get_from_cache() {
365
- local keywords="$1"
366
- local keywords_hash=$(echo -n "$keywords" | md5 2>/dev/null || echo "$keywords" | md5sum | awk '{print $1}')
358
+ rank_and_recommend() {
359
+ local installed="$1"
360
+ local elsabro="$2"
361
+ local external="$3"
362
+ local keywords="$4"
367
363
 
368
- if ! is_cache_valid "$SKILLS_CACHE_FILE"; then
369
- return 1
370
- fi
371
-
372
- # Buscar en caché con jq
373
- if command -v jq &> /dev/null; then
374
- local cached=$(
375
- jq --arg hash "$keywords_hash" '.cache[$hash]?' "$SKILLS_CACHE_FILE" 2>/dev/null
376
- )
377
-
378
- if [ "$cached" != "null" ] && [ -n "$cached" ]; then
379
- log_success "Skill cache hit"
380
- echo "$cached"
381
- return 0
382
- fi
364
+ if ! has_command jq; then
365
+ echo "$external"
366
+ return
383
367
  fi
384
368
 
385
- return 1
386
- }
387
-
388
- # Guardar resultado en caché
389
- save_to_cache() {
390
- local keywords="$1"
391
- local skills="$2"
392
- local keywords_hash=$(echo -n "$keywords" | md5 2>/dev/null || echo "$keywords" | md5sum | awk '{print $1}')
369
+ # Combinar todas las fuentes, deduplicar por id, y rankear
370
+ jq -n \
371
+ --argjson installed "$installed" \
372
+ --argjson elsabro "$elsabro" \
373
+ --argjson external "$external" \
374
+ --argjson keywords "$keywords" \
375
+ '
376
+ # Lista de IDs instalados para filtrar
377
+ ($installed | map(.id)) as $installed_ids |
378
+
379
+ # Combinar elsabro + external, excluir ya instalados
380
+ ($elsabro + $external)
381
+ | map(select(.id as $id | $installed_ids | index($id) | not))
382
+
383
+ # Deduplicar por id (preferir elsabro sobre external)
384
+ | group_by(.id)
385
+ | map(
386
+ sort_by(if .source == "elsabro" then 0 else 1 end)
387
+ | first
388
+ )
393
389
 
394
- ensure_dirs
390
+ # Rankear
391
+ | map(. + {
392
+ rank: (
393
+ ((.match_score // 0) * 100) +
394
+ (if .source == "elsabro" then 20 else 0 end) +
395
+ (if .source == "skills-registry" then 10 else 0 end)
396
+ )
397
+ })
395
398
 
396
- # Crear o actualizar caché
397
- if [ -f "$SKILLS_CACHE_FILE" ]; then
398
- jq --arg hash "$keywords_hash" --argjson skills "$skills" \
399
- '.cache[$hash] = $skills' "$SKILLS_CACHE_FILE" > "$SKILLS_CACHE_FILE.tmp"
400
- mv "$SKILLS_CACHE_FILE.tmp" "$SKILLS_CACHE_FILE"
401
- else
402
- # Crear caché nuevo
403
- jq -n --arg hash "$keywords_hash" --argjson skills "$skills" \
404
- '{cache: {($hash): $skills}}' > "$SKILLS_CACHE_FILE"
405
- fi
399
+ # Ordenar por rank descendente y limitar
400
+ | sort_by(.rank) | reverse
401
+ | .[0:'"$MAX_RESULTS"']
402
+ '
406
403
  }
407
404
 
408
405
  # ============================================================================
409
- # 6. OUTPUT Y FORMATEO
406
+ # 7. GENERAR OUTPUT JSON
410
407
  # ============================================================================
411
408
 
412
- # Formatea output final como JSON normalizado
413
- # Entrada: keywords, skills de cada fuente, skills rankeados
414
- # Salida: JSON con estructura completa
415
- format_discovery_output() {
409
+ generate_output() {
416
410
  local keywords="$1"
417
- local elsabro_skills="$2"
418
- local external_skills="$3"
419
- local ranked_skills="$4"
420
-
421
- if ! command -v jq &> /dev/null; then
422
- # Fallback si jq no está disponible
423
- cat <<EOF
424
- {
425
- "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
426
- "status": "error",
427
- "message": "jq not available"
428
- }
429
- EOF
411
+ local installed="$2"
412
+ local elsabro="$3"
413
+ local external="$4"
414
+ local recommended="$5"
415
+ local updates="$6"
416
+
417
+ if ! has_command jq; then
418
+ cat <<NOJQ
419
+ {"status":"error","message":"jq not available","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
420
+ NOJQ
430
421
  return 1
431
422
  fi
432
423
 
433
- # Contar elementos
434
- local elsabro_count=$(echo "$elsabro_skills" | jq 'length')
435
- local external_count=$(echo "$external_skills" | jq 'length')
436
- local ranked_count=$(echo "$ranked_skills" | jq 'length')
437
-
438
- # Extraer top 3 recomendaciones
439
- local recommendations=$(
440
- echo "$ranked_skills" | jq -r '.[0:3] | map(.id) | .[]' | jq -Rs 'split("\n") | map(select(length > 0))'
441
- )
442
-
443
- # Construir output final
444
- cat <<EOF | jq -c '.'
445
- {
446
- "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
447
- "keywords_detected": $keywords,
448
- "discovery": {
449
- "elsabro": {
450
- "count": $elsabro_count,
451
- "skills": $elsabro_skills
452
- },
453
- "external": {
454
- "count": $external_count,
455
- "skills": $external_skills
456
- }
457
- },
458
- "ranked": {
459
- "count": $ranked_count,
460
- "skills": $ranked_skills,
461
- "recommendations": $recommendations
462
- },
463
- "status": "success"
464
- }
465
- EOF
424
+ jq -n \
425
+ --argjson keywords "$keywords" \
426
+ --argjson installed "$installed" \
427
+ --argjson elsabro "$elsabro" \
428
+ --argjson external "$external" \
429
+ --argjson recommended "$recommended" \
430
+ --argjson updates "$updates" \
431
+ '{
432
+ timestamp: (now | strftime("%Y-%m-%dT%H:%M:%SZ")),
433
+ status: "success",
434
+ keywords: $keywords,
435
+ installed: {
436
+ count: ($installed | length),
437
+ skills: $installed
438
+ },
439
+ discovered: {
440
+ elsabro: {
441
+ count: ($elsabro | length),
442
+ skills: $elsabro
443
+ },
444
+ registry: {
445
+ count: ($external | length),
446
+ skills: $external
447
+ }
448
+ },
449
+ recommended: {
450
+ count: ($recommended | length),
451
+ skills: $recommended,
452
+ install_commands: [
453
+ $recommended[]
454
+ | select(.install_cmd != null)
455
+ | {name: .id, command: .install_cmd}
456
+ ]
457
+ },
458
+ updates: {
459
+ count: ($updates | length),
460
+ available: $updates
461
+ },
462
+ actions: {
463
+ install_all: (
464
+ if ($recommended | length) > 0
465
+ then "npx skills add -g " + ([$recommended[].id] | join(" "))
466
+ else null
467
+ end
468
+ ),
469
+ update_all: (
470
+ if ($updates | length) > 0
471
+ then "npx skills update"
472
+ else null
473
+ end
474
+ )
475
+ }
476
+ }'
466
477
  }
467
478
 
468
479
  # ============================================================================
469
- # 7. FUNCIÓN PRINCIPAL
480
+ # 8. MAIN
470
481
  # ============================================================================
471
482
 
472
483
  main() {
473
- local task_description="${1:-}"
484
+ local task="${1:-}"
474
485
  local complexity="${2:-medium}"
475
486
 
476
- # Validación de entrada
477
- if [ -z "$task_description" ]; then
478
- cat <<EOF | jq -c '.'
479
- {
480
- "status": "error",
481
- "message": "Task description required",
482
- "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
483
- }
484
- EOF
487
+ # Validate complexity
488
+ case "$complexity" in
489
+ low|medium|high) ;;
490
+ *) complexity="medium" ;;
491
+ esac
492
+
493
+ if [ -z "$task" ]; then
494
+ jq -n '{"status":"error","message":"Task description required","timestamp":(now | strftime("%Y-%m-%dT%H:%M:%SZ"))}'
485
495
  exit 1
486
496
  fi
487
497
 
488
- log_info "Analizando: $task_description (complejidad: $complexity)"
498
+ ensure_dirs
499
+ log_info "Skill Discovery para: $task (complejidad: $complexity)"
489
500
 
490
- # ---- FASE 1: Extraer keywords ----
501
+ # --- Fase 1: Keywords ---
491
502
  log_info "Extrayendo keywords..."
492
- local keywords=$(extract_keywords "$task_description")
493
- log_success "Keywords: $(echo $keywords | jq -r 'join(", ")')"
503
+ local keywords
504
+ keywords=$(extract_keywords "$task")
505
+ log_success "Keywords: $(echo "$keywords" | jq -r 'join(", ")' 2>/dev/null || echo "$keywords")"
494
506
 
495
- # ---- FASE 2: Buscar skills (con caché) ----
496
- local keywords_str=$(echo "$keywords" | jq -r 'join(",")')
497
- local cached_result=""
507
+ # --- Fase 2: Cache check ---
508
+ local cache_key
509
+ cache_key=$(echo -n "${task}|${complexity}" | md5 2>/dev/null || echo -n "${task}|${complexity}" | md5sum 2>/dev/null | awk '{print $1}' || echo "nocache")
498
510
 
499
- if cached_result=$(get_from_cache "$keywords_str"); then
500
- log_success "Usando resultados del caché"
501
- # Output y exit
502
- echo "$cached_result"
511
+ if is_cache_valid "$cache_key"; then
512
+ log_success "Cache hit"
513
+ get_from_cache "$cache_key"
503
514
  exit 0
504
515
  fi
505
516
 
506
- # ---- FASE 3: Descubrir skills de múltiples fuentes ----
507
- log_info "Descubriendo skills internos (ELSABRO)..."
508
- local elsabro_skills=$(discover_elsabro_skills "$keywords")
509
- log_success "ELSABRO skills encontrados: $(echo $elsabro_skills | jq 'length')"
510
-
511
- log_info "Descubriendo skills externos (skills.sh)..."
512
- local external_skills=$(discover_external_skills "$keywords")
513
- log_success "External skills encontrados: $(echo $external_skills | jq 'length')"
514
-
515
- # ---- FASE 4: Combinar y rankear ----
516
- log_info "Combinando y rankeando skills..."
517
- local all_skills=$(echo "[$elsabro_skills, $external_skills]" | jq -s 'add // []')
518
- local ranked=$(validate_and_rank_skills "$all_skills")
519
- log_success "Skills rankeados: $(echo $ranked | jq 'length')"
520
-
521
- # ---- FASE 5: Formatear output ----
522
- log_info "Formateando output..."
523
- local output=$(format_discovery_output "$keywords" "$elsabro_skills" "$external_skills" "$ranked")
524
-
525
- # Guardar en caché
526
- save_to_cache "$keywords_str" "$output"
527
-
528
- # Output final (STDOUT) - esto es lo que captura el Flow Engine
517
+ # --- Fase 3: Escanear skills instalados ---
518
+ log_info "Escaneando skills instalados..."
519
+ local installed
520
+ installed=$(scan_installed_skills)
521
+ log_success "Instalados: $(echo "$installed" | jq 'length' 2>/dev/null || echo 0)"
522
+
523
+ # --- Fase 4: Escanear skills ELSABRO locales ---
524
+ log_info "Escaneando skills ELSABRO..."
525
+ local elsabro
526
+ elsabro=$(scan_elsabro_skills "$keywords")
527
+ log_success "ELSABRO: $(echo "$elsabro" | jq 'length' 2>/dev/null || echo 0)"
528
+
529
+ # --- Fase 5: Descubrir skills externos ---
530
+ log_info "Buscando en skills registry..."
531
+ local query_str
532
+ query_str=$(echo "$keywords" | jq -r 'join(" ")' 2>/dev/null || echo "$task")
533
+ local external
534
+ external=$(discover_via_npx_skills "$query_str")
535
+ log_success "Registry: $(echo "$external" | jq 'length' 2>/dev/null || echo 0)"
536
+
537
+ # --- Fase 6: Check updates ---
538
+ log_info "Verificando actualizaciones..."
539
+ local updates
540
+ updates=$(check_skill_updates)
541
+ log_success "Updates: $(echo "$updates" | jq 'length' 2>/dev/null || echo 0)"
542
+
543
+ # --- Fase 7: Ranking ---
544
+ log_info "Rankeando recomendaciones..."
545
+ local recommended
546
+ recommended=$(rank_and_recommend "$installed" "$elsabro" "$external" "$keywords")
547
+ log_success "Recomendados: $(echo "$recommended" | jq 'length' 2>/dev/null || echo 0)"
548
+
549
+ # --- Fase 8: Output ---
550
+ local output
551
+ output=$(generate_output "$keywords" "$installed" "$elsabro" "$external" "$recommended" "$updates")
552
+
553
+ # Guardar en cache
554
+ save_to_cache "$cache_key" "$output" 2>/dev/null || true
555
+
556
+ # Output final a STDOUT (esto captura el Flow Engine)
529
557
  echo "$output"
530
558
 
531
- log_success "Discovery completado"
532
- exit 0
559
+ log_success "Skill Discovery completado"
533
560
  }
534
561
 
535
562
  # ============================================================================