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.
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/src/api/auth.js +18 -2
- package/src/api/client.js +29 -3
- package/src/api/feedback.js +14 -5
- package/src/api/repos.js +28 -10
- package/src/api/translate.js +90 -0
- package/src/commands/delete.js +15 -1
- package/src/commands/feedback.js +2 -2
- package/src/commands/init.js +5 -1
- package/src/commands/install.js +58 -32
- package/src/commands/postlex.js +53 -35
- package/src/commands/postlex.test.js +48 -18
- package/src/commands/pull.js +5 -1
- package/src/commands/reconcile.js +52 -4
- package/src/commands/release.js +45 -15
- package/src/commands/schema.js +179 -0
- package/src/commands/search.js +34 -22
- package/src/commands/search.test.js +59 -33
- package/src/commands/uninstall.js +20 -11
- package/src/commands/validate.js +33 -11
- package/src/constants/error_codes.js +197 -0
- package/src/constants/exit_codes.js +54 -0
- package/src/constants/next_step_actions.js +133 -0
- package/src/constants/next_step_by_error_code.js +249 -0
- package/src/constants.js +2 -1
- package/src/index.js +51 -7
- package/src/integration/api_envelope.test.js +499 -0
- package/src/integration/bump.test.js +13 -4
- package/src/integration/cli.test.js +169 -147
- package/src/integration/drift.test.js +16 -4
- package/src/integration/install_fresh.test.js +37 -29
- package/src/integration/reconcile.test.js +77 -56
- package/src/integration/release.test.js +48 -31
- package/src/integration/schema.test.js +167 -0
- package/src/schema/envelope.schema.json +73 -0
- package/src/schema/envelope_test_helpers.js +94 -0
- package/src/schema/envelope_validator.js +239 -0
- package/src/schema/envelope_validator.test.js +333 -0
- package/src/ui/envelope.js +171 -0
- package/src/ui/output.js +66 -2
- package/src/utils/errors.js +116 -47
- 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
|
-
|
|
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
|
|
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('
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
|
263
|
-
assert.strictEqual(
|
|
264
|
-
assert.strictEqual(
|
|
265
|
-
assert.ok(
|
|
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
|
|
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
|
|
272
|
-
|
|
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
|
|
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
|
|
281
|
-
|
|
282
|
-
assert.strictEqual(
|
|
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
|
|
296
|
-
it('init --json
|
|
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
|
|
302
|
-
|
|
303
|
-
assert.strictEqual(
|
|
304
|
-
assert.
|
|
305
|
-
assert.ok(
|
|
306
|
-
assert.ok(
|
|
307
|
-
assert.ok(
|
|
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
|
|
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
|
|
323
|
-
|
|
324
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
335
|
-
|
|
336
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
345
|
-
|
|
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
|
|
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
|
|
369
|
-
|
|
370
|
-
assert.strictEqual(
|
|
371
|
-
assert.strictEqual(
|
|
372
|
-
assert.strictEqual(
|
|
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
|
|
382
|
-
it('list --json returns
|
|
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
|
|
389
|
-
|
|
390
|
-
assert.ok('skills' in
|
|
391
|
-
assert.ok('drafts' in
|
|
392
|
-
assert.ok('external' in
|
|
393
|
-
assert.ok(typeof
|
|
394
|
-
assert.ok(Array.isArray(
|
|
395
|
-
assert.ok(Array.isArray(
|
|
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
|
|
426
|
-
|
|
427
|
-
assert.strictEqual(
|
|
428
|
-
assert.strictEqual(
|
|
429
|
-
assert.strictEqual(
|
|
430
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
440
|
-
|
|
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
|
|
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
|
|
450
|
-
|
|
451
|
-
assert.ok(Array.isArray(
|
|
452
|
-
assert.strictEqual(
|
|
453
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
469
|
-
|
|
470
|
-
assert.ok(Array.isArray(
|
|
471
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
493
|
-
|
|
494
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
517
|
-
|
|
518
|
-
assert.strictEqual(
|
|
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('
|
|
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
|
|
562
|
-
|
|
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
|
|
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
|
|
614
|
-
|
|
615
|
-
assert.strictEqual(
|
|
616
|
-
assert.strictEqual(
|
|
617
|
-
assert.ok(Array.isArray(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
assert.
|
|
621
|
-
assert.strictEqual(typeof
|
|
622
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
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
|
|
694
|
-
|
|
695
|
-
assert.
|
|
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
|
|
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
|
|
718
|
-
|
|
719
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
927
|
-
|
|
928
|
-
assert.ok(Array.isArray(
|
|
929
|
-
assert.strictEqual(
|
|
930
|
-
assert.strictEqual(
|
|
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
|
|
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
|
|
944
|
-
|
|
945
|
-
assert.ok(Array.isArray(
|
|
946
|
-
assert.strictEqual(
|
|
947
|
-
assert.strictEqual(
|
|
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
|
|
964
|
-
|
|
965
|
-
assert.strictEqual(
|
|
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
|
|
1021
|
-
|
|
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
|
|
1049
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
/**
|