happyskills 1.20.0 → 1.21.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 (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/package.json +1 -1
  3. package/src/api/profiles.js +76 -0
  4. package/src/commands/agents.js +4 -1
  5. package/src/commands/check.js +4 -1
  6. package/src/commands/convert.js +6 -4
  7. package/src/commands/disable.js +4 -1
  8. package/src/commands/enable.js +4 -1
  9. package/src/commands/install.js +12 -3
  10. package/src/commands/list.js +4 -1
  11. package/src/commands/profile.js +350 -0
  12. package/src/commands/profile.test.js +80 -0
  13. package/src/commands/publish.js +3 -5
  14. package/src/commands/pull.js +7 -7
  15. package/src/commands/reconcile.js +4 -2
  16. package/src/commands/release.js +4 -1
  17. package/src/commands/schema.js +5 -2
  18. package/src/commands/skills-config.js +419 -55
  19. package/src/commands/snapshot.js +4 -1
  20. package/src/commands/update.js +4 -1
  21. package/src/commands/validate.js +5 -2
  22. package/src/config/limits.js +1 -1
  23. package/src/constants/next_step_actions.js +3 -1
  24. package/src/constants.js +1 -0
  25. package/src/engine/installer.js +8 -4
  26. package/src/engine/uninstaller.js +2 -3
  27. package/src/integration/schema.test.js +2 -2
  28. package/src/integration/skills-config.test.js +432 -1
  29. package/src/lock/reader.js +28 -2
  30. package/src/lock/writer.guard.test.js +66 -0
  31. package/src/lock/writer.js +54 -2
  32. package/src/lock/writer.test.js +114 -0
  33. package/src/merge/detector.js +19 -11
  34. package/src/merge/rebase.js +2 -3
  35. package/src/schema/envelope_validator.js +1 -1
  36. package/src/skills_config/configure.js +6 -10
  37. package/src/skills_config/configure.test.js +120 -0
  38. package/src/skills_config/reader.js +30 -13
  39. package/src/skills_config/resolve.js +14 -6
  40. package/src/skills_config/schema_validator.js +245 -0
  41. package/src/skills_config/schema_validator.test.js +153 -0
  42. package/src/skills_config/skills_config.test.js +126 -1
  43. package/src/skills_config/types.js +59 -0
  44. package/src/skills_config/validate.js +344 -0
  45. package/src/skills_config/validate.test.js +214 -0
  46. package/src/skills_config/writer.js +67 -18
  47. package/src/utils/file_lock.js +93 -0
  48. package/src/utils/file_lock.test.js +72 -0
  49. package/src/validation/skill_json_rules.js +34 -4
  50. package/src/validation/skill_json_rules.test.js +156 -0
@@ -0,0 +1,80 @@
1
+ // Unit tests for `happyskills profile` (spec 260710-01 Decision 4 — full CLI
2
+ // parity). Pure-helper coverage + the schema conformance case: the command's
3
+ // declared errors/next_steps must reference only the CLOSED envelope enums.
4
+
5
+ const test = require('node:test')
6
+ const assert = require('node:assert/strict')
7
+
8
+ const { collect_field_edits, parse_crop, sniff_content_type, schema } = require('./profile')
9
+ const { UsageError } = require('../utils/errors')
10
+ const { NEXT_STEP_ACTION_SET, NEXT_STEP_KIND_SET } = require('../constants/next_step_actions')
11
+ const { ERROR_CODE_SET } = require('../constants/error_codes')
12
+ const { COMMANDS } = require('../constants')
13
+
14
+ test('collect_field_edits: undefined = untouched, "" = clear, values pass through', () => {
15
+ assert.deepEqual(collect_field_edits({}), {})
16
+ assert.deepEqual(collect_field_edits({ tagline: 'Ships skills' }), { tagline: 'Ships skills' })
17
+ assert.deepEqual(collect_field_edits({ tagline: '' }), { tagline: null })
18
+ assert.deepEqual(collect_field_edits({ website: 'https://x.example/' }), { website_url: 'https://x.example/' })
19
+ assert.deepEqual(collect_field_edits({ location: '' }), { location: null })
20
+ })
21
+
22
+ test('collect_field_edits: social flags fold into one social_links object ("" clears a key)', () => {
23
+ const fields = collect_field_edits({ github: 'https://github.com/n', twitter: '' })
24
+ assert.deepEqual(fields.social_links, { github: 'https://github.com/n', twitter: '' })
25
+ })
26
+
27
+ test('collect_field_edits: map visibility toggles + mutual exclusions', () => {
28
+ assert.deepEqual(collect_field_edits({ 'hide-map': true }), { expertise_map_hidden: true })
29
+ assert.deepEqual(collect_field_edits({ 'show-map': true }), { expertise_map_hidden: false })
30
+ assert.throws(() => collect_field_edits({ 'hide-map': true, 'show-map': true }), UsageError)
31
+ assert.throws(() => collect_field_edits({ readme: 'x', 'readme-file': 'y.md' }), UsageError)
32
+ })
33
+
34
+ test('collect_field_edits: a valueless string flag is a loud usage error, not a silent true', () => {
35
+ assert.throws(() => collect_field_edits({ tagline: true }), UsageError)
36
+ })
37
+
38
+ test('collect_field_edits: --pin parses names; "" empties the pinned set', () => {
39
+ assert.deepEqual(collect_field_edits({ pin: 'a, b ,c' }).__pin_names, ['a', 'b', 'c'])
40
+ assert.deepEqual(collect_field_edits({ pin: '' }).__pin_names, [])
41
+ })
42
+
43
+ test('parse_crop: x,y,size in 0..1, square must fit', () => {
44
+ assert.deepEqual(parse_crop('0.1,0.05,0.8'), { x: 0.1, y: 0.05, size: 0.8 })
45
+ assert.throws(() => parse_crop('0.6,0,0.5'), UsageError)
46
+ assert.throws(() => parse_crop('0,0'), UsageError)
47
+ assert.throws(() => parse_crop('a,b,c'), UsageError)
48
+ })
49
+
50
+ test('sniff_content_type: magic bytes only — extension and claims are never trusted', () => {
51
+ assert.equal(sniff_content_type(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0])), 'image/jpeg')
52
+ assert.equal(sniff_content_type(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])), 'image/png')
53
+ const webp = Buffer.alloc(16); webp.write('RIFF', 0, 'latin1'); webp.write('WEBP', 8, 'latin1')
54
+ assert.equal(sniff_content_type(webp), 'image/webp')
55
+ assert.equal(sniff_content_type(Buffer.from('<svg onload=x>....')), null)
56
+ assert.equal(sniff_content_type(Buffer.from('GIF89a..........')), null)
57
+ })
58
+
59
+ // ── schema conformance (the CLAUDE.md "conformance test + schema entry" gate) ─
60
+
61
+ test('schema entry: registered command, closed-enum error codes and next_step actions only', () => {
62
+ assert.ok(COMMANDS.includes('profile'), 'profile is in the COMMANDS registry (schema --json includes it)')
63
+ assert.equal(schema.name, 'profile')
64
+ assert.equal(schema.mutation, true)
65
+ assert.ok(schema.purpose.length > 20)
66
+ for (const err of schema.errors) {
67
+ assert.ok(ERROR_CODE_SET.has(err.code), `${err.code} must be a closed-enum error code`)
68
+ if (err.next_step) {
69
+ assert.ok(NEXT_STEP_KIND_SET.has(err.next_step.kind), `${err.next_step.kind} must be a closed kind`)
70
+ assert.ok(NEXT_STEP_ACTION_SET.has(err.next_step.action), `${err.next_step.action} must be a closed action`)
71
+ }
72
+ }
73
+ })
74
+
75
+ test('schema entry: declares the full § II.4.3 parity surface as flags', () => {
76
+ const flag_names = schema.input.flags.map(f => f.name)
77
+ for (const expected of ['tagline', 'location', 'website', 'github', 'readme', 'readme-file', 'pin', 'hide-map', 'public', 'private', 'avatar', 'crop', 'json']) {
78
+ assert.ok(flag_names.includes(expected), `flag --${expected} must be declared`)
79
+ }
80
+ })
@@ -11,7 +11,7 @@ const { collect_files } = require('../utils/file_collector')
11
11
  const { resolve_skill_dir, resolve_skill_owner } = require('../utils/resolve_skill')
12
12
  const { find_project_root } = require('../config/paths')
13
13
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
14
- const { write_lock, update_lock_skills } = require('../lock/writer')
14
+ const { mutate_lock, update_lock_skills } = require('../lock/writer')
15
15
  const { hash_directory } = require('../lock/integrity')
16
16
  const { verify_lock_disk_consistency } = require('../lock/verify')
17
17
  const { validate_skill_md } = require('../validation/skill_md_rules')
@@ -323,12 +323,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
323
323
  if (!hash_err && integrity) updated_entry.integrity = integrity
324
324
  delete updated_entry.merge_parents
325
325
  delete updated_entry.conflict_files
326
- const updated_skills = update_lock_skills(lock_data_to_use, { [existing_lock_key]: updated_entry })
327
- await write_lock(project_root, updated_skills)
326
+ await mutate_lock(project_root, (current) => update_lock_skills(current, { [existing_lock_key]: updated_entry }))
328
327
  post_publish_entry = updated_entry
329
328
  } else {
330
- const updated_skills = update_lock_skills(lock_data_to_use, { [full_name]: new_entry })
331
- await write_lock(project_root, updated_skills)
329
+ await mutate_lock(project_root, (current) => update_lock_skills(current, { [full_name]: new_entry }))
332
330
  post_publish_entry = new_entry
333
331
  }
334
332
 
@@ -13,7 +13,7 @@ const { merge_changelog } = require('../merge/changelog_merge')
13
13
  const { hash_blob } = require('../utils/git_hash')
14
14
  const { satisfies } = require('../utils/semver')
15
15
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
16
- const { write_lock, update_lock_skills } = require('../lock/writer')
16
+ const { mutate_lock, update_lock_skills } = require('../lock/writer')
17
17
  const { hash_directory } = require('../lock/integrity')
18
18
  const { verify_lock_disk_consistency } = require('../lock/verify')
19
19
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
@@ -298,8 +298,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
298
298
  }
299
299
  delete updated_entry.merge_parents
300
300
  delete updated_entry.conflict_files
301
- const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
302
- const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
301
+ const merged_skills = (current) => update_lock_skills(current, { [skill_name]: updated_entry })
302
+ const [wl_err] = await mutate_lock(lock_root(is_global, project_root), merged_skills)
303
303
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
304
304
 
305
305
  // Post-write verification — see installer.js for the rationale.
@@ -549,8 +549,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
549
549
  delete updated_entry.conflict_files
550
550
  updated_entry.merge_parents = [lock_entry.base_commit, cmp_data.head_commit]
551
551
  }
552
- const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
553
- const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
552
+ const merged_skills = (current) => update_lock_skills(current, { [skill_name]: updated_entry })
553
+ const [wl_err] = await mutate_lock(lock_root(is_global, project_root), merged_skills)
554
554
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
555
555
 
556
556
  // Post-merge verification — surface any disagreement between the new lock
@@ -695,8 +695,8 @@ const resolve_pending_conflicts = ({ skill_dir, lock_entry, lock_data, skill_nam
695
695
  }
696
696
  if (unresolved.length > 0) updated_entry.conflict_files = unresolved
697
697
  else delete updated_entry.conflict_files
698
- const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
699
- const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
698
+ const merged_skills = (current) => update_lock_skills(current, { [skill_name]: updated_entry })
699
+ const [wl_err] = await mutate_lock(lock_root(is_global, project_root), merged_skills)
700
700
  if (wl_err) throw wl_err[0]
701
701
 
702
702
  const status = unresolved.length > 0 ? 'conflicts' : 'merged'
@@ -2,7 +2,6 @@ const path = require('path')
2
2
  const fs = require('fs')
3
3
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
4
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
5
- const { write_lock, update_lock_skills } = require('../lock/writer')
6
5
  const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
7
6
  const { write_manifest } = require('../manifest/writer')
8
7
  const { read_manifest } = require('../manifest/reader')
@@ -45,7 +44,10 @@ const ACTION_NEXT_STEPS = {
45
44
  }
46
45
 
47
46
  const resolve_lock_entry = (raw, project_root, is_global) => catch_errors('Failed to resolve lock entry', async () => {
48
- const [, lock_data] = await read_lock(lock_root(is_global, project_root))
47
+ const [lock_data_err, lock_data] = await read_lock(lock_root(is_global, project_root))
48
+ // A corrupt lock must not be read as "nothing installed" — that lie would have us
49
+ // report an empty project, or reinstall over a lock we simply failed to parse.
50
+ if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
49
51
  if (!lock_data) return { full: null, lock_entry: null, lock_data: null }
50
52
  const all = get_all_locked_skills(lock_data)
51
53
  let full = raw
@@ -155,7 +155,10 @@ const orchestrate = (args) => catch_errors('Release failed', async () => {
155
155
  if (manifest_err) throw e('No skill.json found', manifest_err)
156
156
 
157
157
  // Resolve lock entry (if any) for ahead detection + version inference.
158
- const [, lock_data] = await read_lock(project_root)
158
+ const [lock_data_err, lock_data] = await read_lock(project_root)
159
+ // A corrupt lock must not be read as "nothing installed" — that lie would have us
160
+ // report an empty project, or reinstall over a lock we simply failed to parse.
161
+ if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
159
162
  const all_locked = lock_data ? get_all_locked_skills(lock_data) : {}
160
163
  const lock_key = Object.keys(all_locked).find(k => k.endsWith(`/${skill_name}`)) || skill_name
161
164
  const lock_entry = all_locked[lock_key] || null
@@ -11,7 +11,7 @@
11
11
  // so there's no drift.
12
12
 
13
13
  const path = require('path')
14
- const { error: { catch_errors } } = require('puffy-core')
14
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
15
15
  const { COMMANDS, COMMAND_ALIASES, CLI_VERSION, SKILL_JSON } = require('../constants')
16
16
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
17
17
  const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
@@ -127,7 +127,10 @@ const load_command_schema = (name) => {
127
127
  const read_installed_manifests = (is_global) => catch_errors('Failed to read installed skills', async () => {
128
128
  const project_root = find_project_root()
129
129
  const base_dir = skills_dir(is_global, project_root)
130
- const [, lock_data] = await read_lock(lock_root(is_global, project_root))
130
+ const [lock_data_err, lock_data] = await read_lock(lock_root(is_global, project_root))
131
+ // A corrupt lock must not be read as "nothing installed" — that lie would have us
132
+ // report an empty project, or reinstall over a lock we simply failed to parse.
133
+ if (lock_data_err) throw e('Failed to read the lock file', lock_data_err)
131
134
  const locked = get_all_locked_skills(lock_data)
132
135
  const entries = await Promise.all(Object.keys(locked).map(async (slug) => {
133
136
  const short = slug.split('/')[1] || slug