happyskills 0.53.0 → 1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/src/api/auth.js +18 -2
  4. package/src/api/client.js +29 -3
  5. package/src/api/feedback.js +14 -5
  6. package/src/api/repos.js +28 -10
  7. package/src/api/translate.js +90 -0
  8. package/src/commands/delete.js +15 -1
  9. package/src/commands/feedback.js +2 -2
  10. package/src/commands/init.js +5 -1
  11. package/src/commands/install.js +58 -32
  12. package/src/commands/postlex.js +53 -35
  13. package/src/commands/postlex.test.js +48 -18
  14. package/src/commands/pull.js +5 -1
  15. package/src/commands/reconcile.js +52 -4
  16. package/src/commands/release.js +45 -15
  17. package/src/commands/schema.js +179 -0
  18. package/src/commands/search.js +34 -22
  19. package/src/commands/search.test.js +59 -33
  20. package/src/commands/uninstall.js +20 -11
  21. package/src/commands/validate.js +33 -11
  22. package/src/constants/error_codes.js +197 -0
  23. package/src/constants/exit_codes.js +54 -0
  24. package/src/constants/next_step_actions.js +133 -0
  25. package/src/constants/next_step_by_error_code.js +249 -0
  26. package/src/constants.js +2 -1
  27. package/src/index.js +51 -7
  28. package/src/integration/api_envelope.test.js +499 -0
  29. package/src/integration/bump.test.js +13 -4
  30. package/src/integration/cli.test.js +169 -147
  31. package/src/integration/drift.test.js +16 -4
  32. package/src/integration/install_fresh.test.js +37 -29
  33. package/src/integration/reconcile.test.js +77 -56
  34. package/src/integration/release.test.js +48 -31
  35. package/src/integration/schema.test.js +167 -0
  36. package/src/schema/envelope.schema.json +73 -0
  37. package/src/schema/envelope_test_helpers.js +94 -0
  38. package/src/schema/envelope_validator.js +239 -0
  39. package/src/schema/envelope_validator.test.js +333 -0
  40. package/src/ui/envelope.js +171 -0
  41. package/src/ui/output.js +66 -2
  42. package/src/utils/errors.js +116 -47
  43. package/src/utils/intent.js +22 -1
@@ -16,6 +16,11 @@ const { spawnSync } = require('child_process')
16
16
  const fs = require('fs')
17
17
  const os = require('os')
18
18
  const path = require('path')
19
+ const {
20
+ parse_envelope,
21
+ assert_success_envelope,
22
+ assert_error_envelope,
23
+ } = require('../schema/envelope_test_helpers')
19
24
 
20
25
  const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
21
26
  const NODE = process.execPath
@@ -28,11 +33,21 @@ const NODE = process.execPath
28
33
  * opts.cwd lets individual tests run in an isolated directory.
29
34
  */
30
35
  const run = (args = [], env_override = {}, opts = {}) => {
31
- const result = spawnSync(NODE, [CLI, ...args], {
36
+ // Per spec § 8.1, a spawned process (isTTY=false) auto-flips to JSON
37
+ // mode. The integration tests need an EXPLICIT mode declaration so each
38
+ // test asserts on the surface it claims to test. Tests that exercise
39
+ // text-mode behaviour (stderr messages, "Did you mean", help text)
40
+ // inherit --text by default; tests that exercise the JSON envelope pass
41
+ // --json explicitly. CI=false is also set so the CI heuristic doesn't
42
+ // silently flip the mode in pipelines that export CI=true.
43
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
44
+ const final_args = has_mode_flag ? args : [...args, '--text']
45
+ const result = spawnSync(NODE, [CLI, ...final_args], {
32
46
  env: {
33
47
  ...process.env,
34
- NO_COLOR: '1',
48
+ NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
35
49
  HAPPYSKILLS_API_URL: 'http://localhost:0',
50
+ CI: '',
36
51
  ...env_override
37
52
  },
38
53
  encoding: 'utf-8',
@@ -47,15 +62,12 @@ const run = (args = [], env_override = {}, opts = {}) => {
47
62
  }
48
63
 
49
64
  /**
50
- * Parse stdout as JSON. Throws a descriptive assertion error if parsing fails.
65
+ * Parse stdout as a CLI envelope. Validates against the closed envelope
66
+ * schema (spec 260525-cli-default-json). Throws on parse OR shape failure.
67
+ * Every --json CLI output in this suite goes through this helper — that is
68
+ * the test-suite-level invariant required by spec § 13 Session 1b.
51
69
  */
52
- const parse_json_output = (stdout, label = '') => {
53
- try {
54
- return JSON.parse(stdout)
55
- } catch {
56
- assert.fail(`${label ? label + ': ' : ''}stdout is not valid JSON:\n${stdout}`)
57
- }
58
- }
70
+ const parse_json_output = (stdout, label = '') => parse_envelope(stdout, label)
59
71
 
60
72
  /**
61
73
  * Create a temporary directory for a test and return its path.
@@ -249,37 +261,45 @@ describe('CLI — exit codes', () => {
249
261
  // ─── --json flag: output envelope ─────────────────────────────────────────────
250
262
 
251
263
  describe('CLI — --json: stdout is always valid JSON', () => {
252
- it('stdout is valid JSON for a usage error (install bad-skill --json)', () => {
264
+ it('emits an envelope-shaped error for a usage error (install bad-skill --json)', () => {
253
265
  const { stdout, code } = run(['install', 'no-slash', '--json'])
254
266
  assert.strictEqual(code, 2)
255
- const out = parse_json_output(stdout, 'install bad-skill --json')
256
- assert.ok('error' in out, 'top-level key should be "error"')
257
- assert.ok(!('data' in out), 'should not have a "data" key')
267
+ const env = parse_json_output(stdout, 'install bad-skill --json')
268
+ // Envelope is uniformly six-keyed error AND data both present.
269
+ assert_error_envelope(env, 'USAGE_ERROR', 'install bad-skill')
270
+ assert.deepStrictEqual(env.data, {}, 'data must be the empty object on a usage error')
258
271
  })
259
272
 
260
- it('usage error JSON contains code, message, exit_code', () => {
273
+ it('usage error envelope carries code, message, meta.exit_code, and meta.command', () => {
261
274
  const { stdout } = run(['install', 'no-slash', '--json'])
262
- const out = parse_json_output(stdout)
263
- assert.strictEqual(out.error.code, 'USAGE_ERROR')
264
- assert.strictEqual(out.error.exit_code, 2)
265
- assert.ok(typeof out.error.message === 'string' && out.error.message.length > 0)
275
+ const env = parse_json_output(stdout)
276
+ assert.strictEqual(env.error.code, 'USAGE_ERROR')
277
+ assert.strictEqual(env.meta.exit_code, 2, 'exit_code lives on meta, not error')
278
+ assert.ok(!('exit_code' in env.error), 'exit_code must NOT live on error any more')
279
+ assert.strictEqual(env.meta.command, 'install')
280
+ assert.ok(typeof env.error.message === 'string' && env.error.message.length > 0)
266
281
  })
267
282
 
268
- it('unknown command with --json emits JSON error (not plain text)', () => {
283
+ it('unknown command with --json emits the COMMAND_NOT_FOUND envelope', () => {
269
284
  const { stdout, code } = run(['not-a-command', '--json'])
270
285
  assert.strictEqual(code, 2)
271
- const out = parse_json_output(stdout, 'unknown-command --json')
272
- assert.strictEqual(out.error.code, 'USAGE_ERROR')
286
+ const env = parse_json_output(stdout, 'unknown-command --json')
287
+ assert_error_envelope(env, 'COMMAND_NOT_FOUND', 'unknown command')
288
+ // Recovery hint: show_format with --help in commands[].
289
+ assert.strictEqual(env.next_step.action, 'show_format')
290
+ assert.ok(env.next_step.context.commands.some(c => c.includes('--help')))
273
291
  })
274
292
 
275
- it('network error produces JSON with code NETWORK_ERROR', () => {
293
+ it('network error produces an envelope with code NETWORK_ERROR + retry next_step', () => {
276
294
  // search requires a network call; localhost:0 always fails.
277
295
  // --limit is required, so it must be passed to reach the network call.
278
296
  const { stdout, code } = run(['search', 'anything', '--json', '--limit', '10'])
279
297
  assert.strictEqual(code, 4)
280
- const out = parse_json_output(stdout, 'search --json network failure')
281
- assert.strictEqual(out.error.code, 'NETWORK_ERROR')
282
- assert.strictEqual(out.error.exit_code, 4)
298
+ const env = parse_json_output(stdout, 'search --json network failure')
299
+ assert_error_envelope(env, 'NETWORK_ERROR', 'search network')
300
+ assert.strictEqual(env.meta.exit_code, 4)
301
+ assert.strictEqual(env.next_step.action, 'retry', 'NETWORK_ERROR must carry the retry recovery next_step')
302
+ assert.strictEqual(env.next_step.kind, 'recovery')
283
303
  })
284
304
 
285
305
  it('no stdout noise when --json is active (only one JSON object)', () => {
@@ -292,25 +312,26 @@ describe('CLI — --json: stdout is always valid JSON', () => {
292
312
 
293
313
  // ─── --json flag: success data envelope ───────────────────────────────────────
294
314
 
295
- describe('CLI — --json: success responses use { data } envelope', () => {
296
- it('init --json returns { data: { name, files_created, directory } }', () => {
315
+ describe('CLI — --json: success envelopes', () => {
316
+ it('init --json emits a success envelope with name, files_created, directory', () => {
297
317
  const tmp = make_tmp()
298
318
  try {
299
319
  const { stdout, code } = run(['init', 'my-skill', '--json'], {}, { cwd: tmp })
300
320
  assert.strictEqual(code, 0)
301
- const out = parse_json_output(stdout, 'init --json')
302
- assert.ok('data' in out, 'should have top-level "data" key')
303
- assert.strictEqual(out.data.name, 'my-skill')
304
- assert.ok(Array.isArray(out.data.files_created))
305
- assert.ok(out.data.files_created.includes('skill.json'))
306
- assert.ok(out.data.files_created.includes('SKILL.md'))
307
- assert.ok(typeof out.data.directory === 'string')
321
+ const env = parse_json_output(stdout, 'init --json')
322
+ assert_success_envelope(env, 'init --json')
323
+ assert.strictEqual(env.meta.command, 'init')
324
+ assert.strictEqual(env.data.name, 'my-skill')
325
+ assert.ok(Array.isArray(env.data.files_created))
326
+ assert.ok(env.data.files_created.includes('skill.json'))
327
+ assert.ok(env.data.files_created.includes('SKILL.md'))
328
+ assert.ok(typeof env.data.directory === 'string')
308
329
  } finally {
309
330
  fs.rmSync(tmp, { recursive: true, force: true })
310
331
  }
311
332
  })
312
333
 
313
- it('init --json error when skill.json already exists returns { error }', () => {
334
+ it('init --json when skill.json already exists emits an ALREADY_EXISTS error envelope', () => {
314
335
  const tmp = make_tmp()
315
336
  try {
316
337
  // Create .agents/skills/test-skill/skill.json first
@@ -319,37 +340,36 @@ describe('CLI — --json: success responses use { data } envelope', () => {
319
340
  fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{}')
320
341
  const { stdout, code } = run(['init', 'test-skill', '--json'], {}, { cwd: tmp })
321
342
  assert.strictEqual(code, 1)
322
- const out = parse_json_output(stdout, 'init --json duplicate')
323
- assert.ok('error' in out)
324
- assert.strictEqual(out.error.exit_code, 1)
343
+ const env = parse_json_output(stdout, 'init --json duplicate')
344
+ assert_error_envelope(env, 'ALREADY_EXISTS', 'init duplicate')
345
+ assert.strictEqual(env.meta.exit_code, 1)
325
346
  } finally {
326
347
  fs.rmSync(tmp, { recursive: true, force: true })
327
348
  }
328
349
  })
329
350
 
330
- it('logout --json returns { data: { status: "logged_out" } }', () => {
351
+ it('logout --json emits success envelope with data.status', () => {
331
352
  // Use an isolated XDG dir — clear_token on a missing file still succeeds
332
353
  const { stdout, code } = run(['logout', '--json'], { XDG_CONFIG_HOME: ISOLATED_XDG })
333
354
  assert.strictEqual(code, 0)
334
- const out = parse_json_output(stdout, 'logout --json')
335
- assert.ok('data' in out)
336
- assert.strictEqual(out.data.status, 'logged_out')
355
+ const env = parse_json_output(stdout, 'logout --json')
356
+ assert_success_envelope(env)
357
+ assert.strictEqual(env.data.status, 'logged_out')
337
358
  })
338
359
 
339
- it('login --json with no stored credentials returns { error: { code: "INTERACTIVE_REQUIRED" } }', () => {
360
+ it('login --json with no stored credentials emits INTERACTIVE_REQUIRED', () => {
340
361
  const tmp_xdg = make_tmp()
341
362
  try {
342
363
  const { stdout, code } = run(['login', '--json'], { XDG_CONFIG_HOME: tmp_xdg })
343
364
  assert.strictEqual(code, 1)
344
- const out = parse_json_output(stdout, 'login --json not authenticated')
345
- assert.ok('error' in out)
346
- assert.strictEqual(out.error.code, 'INTERACTIVE_REQUIRED')
365
+ const env = parse_json_output(stdout, 'login --json not authenticated')
366
+ assert_error_envelope(env, 'INTERACTIVE_REQUIRED', 'login --json')
347
367
  } finally {
348
368
  fs.rmSync(tmp_xdg, { recursive: true, force: true })
349
369
  }
350
370
  })
351
371
 
352
- it('login --json --browser with stored credentials returns already_logged_in', () => {
372
+ it('login --json --browser with stored credentials emits success with data.status=already_logged_in', () => {
353
373
  const tmp_xdg = make_tmp()
354
374
  try {
355
375
  const creds_dir = path.join(tmp_xdg, 'happyskills')
@@ -365,11 +385,11 @@ describe('CLI — --json: success responses use { data } envelope', () => {
365
385
  }, null, '\t'))
366
386
  const { stdout, code } = run(['login', '--json', '--browser'], { XDG_CONFIG_HOME: tmp_xdg })
367
387
  assert.strictEqual(code, 0)
368
- const out = parse_json_output(stdout, 'login --json --browser with credentials')
369
- assert.ok('data' in out)
370
- assert.strictEqual(out.data.status, 'already_logged_in')
371
- assert.strictEqual(out.data.username, 'testuser')
372
- assert.strictEqual(out.data.email, 'test@example.com')
388
+ const env = parse_json_output(stdout, 'login --json --browser with credentials')
389
+ assert_success_envelope(env)
390
+ assert.strictEqual(env.data.status, 'already_logged_in')
391
+ assert.strictEqual(env.data.username, 'testuser')
392
+ assert.strictEqual(env.data.email, 'test@example.com')
373
393
  } finally {
374
394
  fs.rmSync(tmp_xdg, { recursive: true, force: true })
375
395
  }
@@ -378,30 +398,27 @@ describe('CLI — --json: success responses use { data } envelope', () => {
378
398
 
379
399
  // ─── --json flag: existing commands use { data } envelope ─────────────────────
380
400
 
381
- describe('CLI — --json: existing json commands now use { data } envelope', () => {
382
- it('list --json returns { data: { skills, drafts, external } }', () => {
401
+ describe('CLI — --json: existing json commands use the envelope', () => {
402
+ it('list --json returns success envelope with data.{skills,drafts,external}', () => {
383
403
  // Run in a temp dir with no lock file — produces empty result
384
404
  const tmp = make_tmp()
385
405
  try {
386
406
  const { stdout, code } = run(['list', '--json'], {}, { cwd: tmp })
387
407
  assert.strictEqual(code, 0)
388
- const out = parse_json_output(stdout, 'list --json empty')
389
- assert.ok('data' in out, 'should have top-level "data" key')
390
- assert.ok('skills' in out.data, 'data.skills should exist')
391
- assert.ok('drafts' in out.data, 'data.drafts should exist')
392
- assert.ok('external' in out.data, 'data.external should exist')
393
- assert.ok(typeof out.data.skills === 'object' && !Array.isArray(out.data.skills))
394
- assert.ok(Array.isArray(out.data.drafts))
395
- assert.ok(Array.isArray(out.data.external))
408
+ const env = parse_json_output(stdout, 'list --json empty')
409
+ assert_success_envelope(env)
410
+ assert.ok('skills' in env.data, 'data.skills should exist')
411
+ assert.ok('drafts' in env.data, 'data.drafts should exist')
412
+ assert.ok('external' in env.data, 'data.external should exist')
413
+ assert.ok(typeof env.data.skills === 'object' && !Array.isArray(env.data.skills))
414
+ assert.ok(Array.isArray(env.data.drafts))
415
+ assert.ok(Array.isArray(env.data.external))
396
416
  } finally {
397
417
  fs.rmSync(tmp, { recursive: true, force: true })
398
418
  }
399
419
  })
400
420
 
401
421
  it('list --json classifies init-scaffolded skills as drafts, foreign-shaped ones as external', () => {
402
- // Two on-disk skills, no lock file:
403
- // - "my-draft" has a HappySkills-shaped skill.json (init-style)
404
- // - "my-foreign" has only SKILL.md (foreign / hand-rolled)
405
422
  const tmp = make_tmp()
406
423
  try {
407
424
  const draft_dir = path.join(tmp, '.agents', 'skills', 'my-draft')
@@ -422,35 +439,35 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
422
439
 
423
440
  const { stdout, code } = run(['list', '--json'], {}, { cwd: tmp })
424
441
  assert.strictEqual(code, 0)
425
- const out = parse_json_output(stdout, 'list --json mixed')
426
- assert.strictEqual(out.data.drafts.length, 1, 'should have exactly one draft')
427
- assert.strictEqual(out.data.drafts[0].name, 'my-draft')
428
- assert.strictEqual(out.data.drafts[0].version, '0.1.0')
429
- assert.strictEqual(out.data.external.length, 1, 'should have exactly one external')
430
- assert.strictEqual(out.data.external[0].name, 'my-foreign')
442
+ const env = parse_json_output(stdout, 'list --json mixed')
443
+ assert_success_envelope(env)
444
+ assert.strictEqual(env.data.drafts.length, 1, 'should have exactly one draft')
445
+ assert.strictEqual(env.data.drafts[0].name, 'my-draft')
446
+ assert.strictEqual(env.data.drafts[0].version, '0.1.0')
447
+ assert.strictEqual(env.data.external.length, 1, 'should have exactly one external')
448
+ assert.strictEqual(env.data.external[0].name, 'my-foreign')
431
449
  } finally {
432
450
  fs.rmSync(tmp, { recursive: true, force: true })
433
451
  }
434
452
  })
435
453
 
436
- it('search --json usage error returns { error } not raw text', () => {
454
+ it('search --json usage error emits a USAGE_ERROR envelope', () => {
437
455
  const { stdout, code } = run(['search', '--json'])
438
456
  assert.strictEqual(code, 2)
439
- const out = parse_json_output(stdout, 'search --json no query')
440
- assert.ok('error' in out)
441
- assert.strictEqual(out.error.code, 'USAGE_ERROR')
457
+ const env = parse_json_output(stdout, 'search --json no query')
458
+ assert_error_envelope(env, 'USAGE_ERROR')
442
459
  })
443
460
 
444
- it('check --json with no skills returns { data: { results: [], outdated_count, up_to_date_count } }', () => {
461
+ it('check --json with no skills returns success envelope with results, outdated_count, up_to_date_count', () => {
445
462
  const tmp = make_tmp()
446
463
  try {
447
464
  const { stdout, code } = run(['check', '--json'], {}, { cwd: tmp })
448
465
  assert.strictEqual(code, 0)
449
- const out = parse_json_output(stdout, 'check --json empty')
450
- assert.ok('data' in out)
451
- assert.ok(Array.isArray(out.data.results))
452
- assert.strictEqual(out.data.outdated_count, 0)
453
- assert.strictEqual(out.data.up_to_date_count, 0)
466
+ const env = parse_json_output(stdout, 'check --json empty')
467
+ assert_success_envelope(env)
468
+ assert.ok(Array.isArray(env.data.results))
469
+ assert.strictEqual(env.data.outdated_count, 0)
470
+ assert.strictEqual(env.data.up_to_date_count, 0)
454
471
  } finally {
455
472
  fs.rmSync(tmp, { recursive: true, force: true })
456
473
  }
@@ -460,15 +477,15 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
460
477
  // ─── update --all (smart batch-check) ─────────────────────────────────────────
461
478
 
462
479
  describe('CLI — --json: update --all command', () => {
463
- it('update --all --json with no installed skills returns { data: { results, outdated_count, ... } }', () => {
480
+ it('update --all --json with no installed skills returns success envelope with results', () => {
464
481
  const tmp = make_tmp()
465
482
  try {
466
483
  const { stdout, code } = run(['update', '--all', '--json'], {}, { cwd: tmp })
467
484
  assert.strictEqual(code, 0)
468
- const out = parse_json_output(stdout, 'update --all --json empty')
469
- assert.ok('data' in out)
470
- assert.ok(Array.isArray(out.data.results))
471
- assert.strictEqual(out.data.outdated_count, 0)
485
+ const env = parse_json_output(stdout, 'update --all --json empty')
486
+ assert_success_envelope(env)
487
+ assert.ok(Array.isArray(env.data.results))
488
+ assert.strictEqual(env.data.outdated_count, 0)
472
489
  } finally {
473
490
  fs.rmSync(tmp, { recursive: true, force: true })
474
491
  }
@@ -486,13 +503,12 @@ describe('CLI — setup command', () => {
486
503
  assert.match(stdout, /Examples:/)
487
504
  })
488
505
 
489
- it('setup --json produces a network error (JSON) when API is unreachable', () => {
506
+ it('setup --json produces a NETWORK_ERROR envelope when API is unreachable', () => {
490
507
  const { stdout, code } = run(['setup', '--json'])
491
508
  assert.strictEqual(code, 4)
492
- const out = parse_json_output(stdout, 'setup --json network failure')
493
- assert.ok('error' in out)
494
- assert.strictEqual(out.error.code, 'NETWORK_ERROR')
495
- assert.strictEqual(out.error.exit_code, 4)
509
+ const env = parse_json_output(stdout, 'setup --json network failure')
510
+ assert_error_envelope(env, 'NETWORK_ERROR')
511
+ assert.strictEqual(env.meta.exit_code, 4)
496
512
  })
497
513
  })
498
514
 
@@ -507,16 +523,15 @@ describe('CLI — self-update command', () => {
507
523
  assert.match(stdout, /Examples:/)
508
524
  })
509
525
 
510
- it('self-update --json produces a network error (JSON) when npm registry is unreachable', () => {
526
+ it('self-update --json produces a NETWORK_ERROR envelope when npm registry is unreachable', () => {
511
527
  const { stdout, code } = run(
512
528
  ['self-update', '--json'],
513
529
  { HAPPYSKILLS_NPM_REGISTRY_URL: 'http://localhost:0' }
514
530
  )
515
531
  assert.strictEqual(code, 4)
516
- const out = parse_json_output(stdout, 'self-update --json network failure')
517
- assert.ok('error' in out)
518
- assert.strictEqual(out.error.code, 'NETWORK_ERROR')
519
- assert.strictEqual(out.error.exit_code, 4)
532
+ const env = parse_json_output(stdout, 'self-update --json network failure')
533
+ assert_error_envelope(env, 'NETWORK_ERROR')
534
+ assert.strictEqual(env.meta.exit_code, 4)
520
535
  })
521
536
  })
522
537
 
@@ -553,14 +568,13 @@ describe('CLI — validate command', () => {
553
568
  }
554
569
  })
555
570
 
556
- it('exits 2 (JSON) when skill does not exist', () => {
571
+ it('emits USAGE_ERROR envelope (exit 2) when skill does not exist', () => {
557
572
  const tmp = make_tmp()
558
573
  try {
559
574
  const { stdout, code } = run(['validate', 'nonexistent', '--json'], {}, { cwd: tmp })
560
575
  assert.strictEqual(code, 2)
561
- const out = parse_json_output(stdout, 'validate nonexistent --json')
562
- assert.ok('error' in out)
563
- assert.strictEqual(out.error.code, 'USAGE_ERROR')
576
+ const env = parse_json_output(stdout, 'validate nonexistent --json')
577
+ assert_error_envelope(env, 'USAGE_ERROR')
564
578
  } finally {
565
579
  fs.rmSync(tmp, { recursive: true, force: true })
566
580
  }
@@ -598,7 +612,7 @@ describe('CLI — validate command', () => {
598
612
  }
599
613
  })
600
614
 
601
- it('--json returns correct schema for a valid skill', () => {
615
+ it('--json returns a success envelope for a valid skill', () => {
602
616
  const tmp = make_tmp()
603
617
  const skill_dir = path.join(tmp, '.agents', 'skills', 'test-skill')
604
618
  fs.mkdirSync(skill_dir, { recursive: true })
@@ -610,22 +624,24 @@ describe('CLI — validate command', () => {
610
624
  try {
611
625
  const { stdout, code } = run(['validate', 'test-skill', '--json'], {}, { cwd: tmp })
612
626
  assert.strictEqual(code, 0)
613
- const out = parse_json_output(stdout, 'validate --json valid skill')
614
- assert.ok('data' in out)
615
- assert.strictEqual(out.data.skill, 'test-skill')
616
- assert.strictEqual(out.data.valid, true)
617
- assert.ok(Array.isArray(out.data.errors))
618
- assert.ok(Array.isArray(out.data.warnings))
619
- assert.strictEqual(typeof out.data.checks_passed, 'number')
620
- assert.strictEqual(typeof out.data.checks_failed, 'number')
621
- assert.strictEqual(typeof out.data.checks_warned, 'number')
622
- assert.strictEqual(out.data.checks_failed, 0)
627
+ const env = parse_json_output(stdout, 'validate --json valid skill')
628
+ assert_success_envelope(env)
629
+ assert.strictEqual(env.data.skill, 'test-skill')
630
+ assert.strictEqual(env.data.valid, true)
631
+ assert.ok(Array.isArray(env.data.errors))
632
+ // Note: validate's content-level warnings live INSIDE data, NOT in
633
+ // the top-level envelope.warnings (those are envelope-level).
634
+ assert.ok(Array.isArray(env.data.warnings))
635
+ assert.strictEqual(typeof env.data.checks_passed, 'number')
636
+ assert.strictEqual(typeof env.data.checks_failed, 'number')
637
+ assert.strictEqual(typeof env.data.checks_warned, 'number')
638
+ assert.strictEqual(env.data.checks_failed, 0)
623
639
  } finally {
624
640
  fs.rmSync(tmp, { recursive: true, force: true })
625
641
  }
626
642
  })
627
643
 
628
- it('--json returns errors for an invalid skill', () => {
644
+ it('--json returns a VALIDATION_FAILED error envelope for an invalid skill', () => {
629
645
  const tmp = make_tmp()
630
646
  const skill_dir = path.join(tmp, '.agents', 'skills', 'bad-skill')
631
647
  fs.mkdirSync(skill_dir, { recursive: true })
@@ -634,10 +650,13 @@ describe('CLI — validate command', () => {
634
650
  try {
635
651
  const { stdout, code } = run(['validate', 'bad-skill', '--json'], {}, { cwd: tmp })
636
652
  assert.strictEqual(code, 1)
637
- const out = parse_json_output(stdout, 'validate --json invalid skill')
638
- assert.strictEqual(out.data.valid, false)
639
- assert.ok(out.data.errors.length > 0)
640
- assert.ok(out.data.checks_failed > 0)
653
+ const env = parse_json_output(stdout, 'validate --json invalid skill')
654
+ assert_error_envelope(env, 'VALIDATION_FAILED')
655
+ // Field-level violations live under error.validation_errors per
656
+ // spec § 4.4 (domain-specific extras alongside code/message).
657
+ assert.ok(Array.isArray(env.error.validation_errors) && env.error.validation_errors.length > 0)
658
+ // fix_validation_errors recovery action.
659
+ assert.strictEqual(env.next_step.action, 'fix_validation_errors')
641
660
  } finally {
642
661
  fs.rmSync(tmp, { recursive: true, force: true })
643
662
  }
@@ -674,7 +693,7 @@ describe('CLI — delete command', () => {
674
693
  assert.ok(stdout.toLowerCase().includes('delete'))
675
694
  })
676
695
 
677
- it('delete acme/deploy-aws --json without -y exits with code 1 (confirmation required)', () => {
696
+ it('delete acme/deploy-aws --json without -y emits CONFIRMATION_REQUIRED with confirm_destructive', () => {
678
697
  const tmp_xdg = make_tmp()
679
698
  try {
680
699
  const creds_dir = path.join(tmp_xdg, 'happyskills')
@@ -690,15 +709,18 @@ describe('CLI — delete command', () => {
690
709
  }, null, '\t'))
691
710
  const { stdout, code } = run(['delete', 'acme/deploy-aws', '--json'], { XDG_CONFIG_HOME: tmp_xdg })
692
711
  assert.strictEqual(code, 1)
693
- const out = parse_json_output(stdout, 'delete --json no -y')
694
- assert.ok('error' in out)
695
- assert.ok(out.error.message.toLowerCase().includes('confirmation'))
712
+ const env = parse_json_output(stdout, 'delete --json no -y')
713
+ assert_error_envelope(env, 'CONFIRMATION_REQUIRED')
714
+ assert.strictEqual(env.next_step.kind, 'confirmation')
715
+ assert.strictEqual(env.next_step.action, 'confirm_destructive')
716
+ assert.ok(env.next_step.context.commands.some(c => /\-y/.test(c)))
717
+ assert.strictEqual(env.next_step.principal_authorization_required, true)
696
718
  } finally {
697
719
  fs.rmSync(tmp_xdg, { recursive: true, force: true })
698
720
  }
699
721
  })
700
722
 
701
- it('delete acme/deploy-aws --json -y exits with code 4 (network error)', () => {
723
+ it('delete acme/deploy-aws --json -y emits NETWORK_ERROR envelope when registry is unreachable', () => {
702
724
  const tmp_xdg = make_tmp()
703
725
  try {
704
726
  const creds_dir = path.join(tmp_xdg, 'happyskills')
@@ -714,10 +736,9 @@ describe('CLI — delete command', () => {
714
736
  }, null, '\t'))
715
737
  const { stdout, code } = run(['delete', 'acme/deploy-aws', '--json', '-y'], { XDG_CONFIG_HOME: tmp_xdg })
716
738
  assert.strictEqual(code, 4)
717
- const out = parse_json_output(stdout, 'delete --json -y network failure')
718
- assert.ok('error' in out)
719
- assert.strictEqual(out.error.code, 'NETWORK_ERROR')
720
- assert.strictEqual(out.error.exit_code, 4)
739
+ const env = parse_json_output(stdout, 'delete --json -y network failure')
740
+ assert_error_envelope(env, 'NETWORK_ERROR')
741
+ assert.strictEqual(env.meta.exit_code, 4)
721
742
  } finally {
722
743
  fs.rmSync(tmp_xdg, { recursive: true, force: true })
723
744
  }
@@ -916,35 +937,35 @@ describe('enable / disable commands', () => {
916
937
  }
917
938
  })
918
939
 
919
- it('disable --json returns structured results', () => {
940
+ it('disable --json returns a success envelope with data.results', () => {
920
941
  const { root, cleanup: clean } = scaffold_project([
921
942
  { full: 'acme/deploy-aws', short: 'deploy-aws' }
922
943
  ])
923
944
  try {
924
945
  const { code, stdout } = run(['disable', 'acme/deploy-aws', '--agents', 'claude', '--json'], {}, { cwd: root })
925
946
  assert.strictEqual(code, 0)
926
- const out = parse_json_output(stdout, 'disable --json')
927
- assert.ok(out.data)
928
- assert.ok(Array.isArray(out.data.results))
929
- assert.strictEqual(out.data.results[0].skill, 'acme/deploy-aws')
930
- assert.strictEqual(out.data.results[0].status, 'disabled')
947
+ const env = parse_json_output(stdout, 'disable --json')
948
+ assert_success_envelope(env)
949
+ assert.ok(Array.isArray(env.data.results))
950
+ assert.strictEqual(env.data.results[0].skill, 'acme/deploy-aws')
951
+ assert.strictEqual(env.data.results[0].status, 'disabled')
931
952
  } finally {
932
953
  clean()
933
954
  }
934
955
  })
935
956
 
936
- it('enable --json returns structured results', () => {
957
+ it('enable --json returns a success envelope with data.results', () => {
937
958
  const { root, cleanup: clean } = scaffold_project([
938
959
  { full: 'acme/deploy-aws', short: 'deploy-aws', enabled: false }
939
960
  ])
940
961
  try {
941
962
  const { code, stdout } = run(['enable', 'acme/deploy-aws', '--agents', 'claude', '--json'], {}, { cwd: root })
942
963
  assert.strictEqual(code, 0)
943
- const out = parse_json_output(stdout, 'enable --json')
944
- assert.ok(out.data)
945
- assert.ok(Array.isArray(out.data.results))
946
- assert.strictEqual(out.data.results[0].skill, 'acme/deploy-aws')
947
- assert.strictEqual(out.data.results[0].status, 'enabled')
964
+ const env = parse_json_output(stdout, 'enable --json')
965
+ assert_success_envelope(env)
966
+ assert.ok(Array.isArray(env.data.results))
967
+ assert.strictEqual(env.data.results[0].skill, 'acme/deploy-aws')
968
+ assert.strictEqual(env.data.results[0].status, 'enabled')
948
969
  } finally {
949
970
  clean()
950
971
  }
@@ -960,9 +981,10 @@ describe('list — enabled column', () => {
960
981
  try {
961
982
  const { code, stdout } = run(['list', '--json', '--agents', 'claude'], {}, { cwd: root })
962
983
  assert.strictEqual(code, 0)
963
- const out = parse_json_output(stdout, 'list --json')
964
- assert.strictEqual(out.data.skills['acme/deploy-aws'].enabled, true)
965
- assert.strictEqual(out.data.skills['acme/monitoring'].enabled, false)
984
+ const env = parse_json_output(stdout, 'list --json')
985
+ assert_success_envelope(env)
986
+ assert.strictEqual(env.data.skills['acme/deploy-aws'].enabled, true)
987
+ assert.strictEqual(env.data.skills['acme/monitoring'].enabled, false)
966
988
  } finally {
967
989
  clean()
968
990
  }
@@ -1014,11 +1036,11 @@ describe('CLI — versions command', () => {
1014
1036
  assert.ok(stderr.toLowerCase().includes('limit'))
1015
1037
  })
1016
1038
 
1017
- it('versions acme/deploy-aws --json fails with NETWORK_ERROR (no server)', () => {
1039
+ it('versions acme/deploy-aws --json fails with NETWORK_ERROR envelope (no server)', () => {
1018
1040
  const { stdout, code } = run(['versions', 'acme/deploy-aws', '--json'])
1019
1041
  assert.strictEqual(code, 4)
1020
- const out = parse_json_output(stdout, 'versions --json network failure')
1021
- assert.strictEqual(out.error.code, 'NETWORK_ERROR')
1042
+ const env = parse_json_output(stdout, 'versions --json network failure')
1043
+ assert_error_envelope(env, 'NETWORK_ERROR')
1022
1044
  })
1023
1045
  })
1024
1046
 
@@ -1042,10 +1064,10 @@ describe('CLI — changelog command', () => {
1042
1064
  assert.ok(stderr.includes('owner/name format'))
1043
1065
  })
1044
1066
 
1045
- it('changelog acme/deploy-aws --json fails with NETWORK_ERROR (no server)', () => {
1067
+ it('changelog acme/deploy-aws --json fails with NETWORK_ERROR envelope (no server)', () => {
1046
1068
  const { stdout, code } = run(['changelog', 'acme/deploy-aws', '--json'])
1047
1069
  assert.strictEqual(code, 4)
1048
- const out = parse_json_output(stdout, 'changelog --json network failure')
1049
- assert.strictEqual(out.error.code, 'NETWORK_ERROR')
1070
+ const env = parse_json_output(stdout, 'changelog --json network failure')
1071
+ assert_error_envelope(env, 'NETWORK_ERROR')
1050
1072
  })
1051
1073
  })
@@ -29,6 +29,10 @@ const { spawnSync } = require('child_process')
29
29
  const fs = require('fs')
30
30
  const os = require('os')
31
31
  const path = require('path')
32
+ const {
33
+ parse_envelope,
34
+ assert_success_envelope,
35
+ } = require('../schema/envelope_test_helpers')
32
36
 
33
37
  const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
34
38
  const NODE = process.execPath
@@ -36,11 +40,14 @@ const NODE = process.execPath
36
40
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'happyskills-drift-test-'))
37
41
 
38
42
  const run = (args, opts) => {
39
- const result = spawnSync(NODE, [CLI, ...args], {
43
+ const has_mode_flag = args.includes('--json') || args.includes('--text')
44
+ const final_args = has_mode_flag ? args : [...args, '--text']
45
+ const result = spawnSync(NODE, [CLI, ...final_args], {
40
46
  env: {
41
47
  ...process.env,
42
- NO_COLOR: '1',
48
+ NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
43
49
  HAPPYSKILLS_API_URL: 'http://localhost:0',
50
+ CI: '',
44
51
  ...(opts?.env || {})
45
52
  },
46
53
  encoding: 'utf-8',
@@ -50,9 +57,14 @@ const run = (args, opts) => {
50
57
  return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
51
58
  }
52
59
 
60
+ // Validates envelope shape + parses. Each --json output below is also a
61
+ // conformance test for the spec 260525 envelope.
53
62
  const parse_json = (stdout, label) => {
54
- try { return JSON.parse(stdout) }
55
- catch { assert.fail(`${label}: stdout is not valid JSON:\n${stdout}`) }
63
+ const env = parse_envelope(stdout, label)
64
+ // Status/check/list emit success envelopes regardless of drift — drift
65
+ // is data, not error. Locking that down here.
66
+ assert_success_envelope(env, label)
67
+ return env
56
68
  }
57
69
 
58
70
  /**