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.
- package/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/api/profiles.js +76 -0
- package/src/commands/agents.js +4 -1
- package/src/commands/check.js +4 -1
- package/src/commands/convert.js +6 -4
- package/src/commands/disable.js +4 -1
- package/src/commands/enable.js +4 -1
- package/src/commands/install.js +12 -3
- package/src/commands/list.js +4 -1
- package/src/commands/profile.js +350 -0
- package/src/commands/profile.test.js +80 -0
- package/src/commands/publish.js +3 -5
- package/src/commands/pull.js +7 -7
- package/src/commands/reconcile.js +4 -2
- package/src/commands/release.js +4 -1
- package/src/commands/schema.js +5 -2
- package/src/commands/skills-config.js +419 -55
- package/src/commands/snapshot.js +4 -1
- package/src/commands/update.js +4 -1
- package/src/commands/validate.js +5 -2
- package/src/config/limits.js +1 -1
- package/src/constants/next_step_actions.js +3 -1
- package/src/constants.js +1 -0
- package/src/engine/installer.js +8 -4
- package/src/engine/uninstaller.js +2 -3
- package/src/integration/schema.test.js +2 -2
- package/src/integration/skills-config.test.js +432 -1
- package/src/lock/reader.js +28 -2
- package/src/lock/writer.guard.test.js +66 -0
- package/src/lock/writer.js +54 -2
- package/src/lock/writer.test.js +114 -0
- package/src/merge/detector.js +19 -11
- package/src/merge/rebase.js +2 -3
- package/src/schema/envelope_validator.js +1 -1
- package/src/skills_config/configure.js +6 -10
- package/src/skills_config/configure.test.js +120 -0
- package/src/skills_config/reader.js +30 -13
- package/src/skills_config/resolve.js +14 -6
- package/src/skills_config/schema_validator.js +245 -0
- package/src/skills_config/schema_validator.test.js +153 -0
- package/src/skills_config/skills_config.test.js +126 -1
- package/src/skills_config/types.js +59 -0
- package/src/skills_config/validate.js +344 -0
- package/src/skills_config/validate.test.js +214 -0
- package/src/skills_config/writer.js +67 -18
- package/src/utils/file_lock.js +93 -0
- package/src/utils/file_lock.test.js +72 -0
- package/src/validation/skill_json_rules.js +34 -4
- 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
|
+
})
|
package/src/commands/publish.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/commands/pull.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
302
|
-
const [wl_err] = await
|
|
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(
|
|
553
|
-
const [wl_err] = await
|
|
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(
|
|
699
|
-
const [wl_err] = await
|
|
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
|
package/src/commands/release.js
CHANGED
|
@@ -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
|
package/src/commands/schema.js
CHANGED
|
@@ -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
|