happyskills 0.39.1 → 0.41.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 +16 -0
- package/package.json +1 -1
- package/src/api/push.js +8 -3
- package/src/api/repos.js +8 -1
- package/src/commands/changelog.js +125 -0
- package/src/commands/changelog.test.js +69 -0
- package/src/commands/publish.js +11 -1
- package/src/commands/publish.test.js +21 -0
- package/src/commands/versions.js +92 -0
- package/src/commands/versions.test.js +53 -0
- package/src/constants.js +3 -1
- package/src/index.js +2 -0
- package/src/integration/cli.test.js +65 -0
- package/src/manifest/validator.js +5 -1
- package/src/manifest/validator.test.js +22 -0
- package/src/validation/skill_md_rules.js +17 -0
- package/src/validation/skill_md_rules.test.js +34 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.41.0] - 2026-05-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `versions` command — list every published version of a skill, newest first. Useful for deciding which version to install when the latest is not always the right pick. Supports `--limit <n>` and `--json`. Backed by the existing `GET /repos/:owner/:repo/refs` endpoint, so the same access rules apply (public skills require no auth; private/workspace skills require read access).
|
|
14
|
+
- Add `changelog` command — print a skill's `CHANGELOG.md` as it exists at the latest version, or at a specific version with `--version <ver>`. Useful for reasoning about when a feature shipped before pinning to an older release. Falls back to a synthesized changelog built from registry release messages when the skill has no `CHANGELOG.md`. Supports `--json`. Resolves the file via `GET /tree?ref=...` + `GET /blob/:sha`, both of which enforce read access server-side.
|
|
15
|
+
- Add 250-char description soft-cap warning to `validate` for `SKILL.md`. Above the soft cap, the warning recommends decomposing the skill into a focused family or applying the AUDIT/LOSSLESS/LOSSY compression procedure — flags mega-skill descriptions before they hit the registry.
|
|
16
|
+
|
|
17
|
+
## [0.40.0] - 2026-05-03
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Propagate the fork relationship to the server on the first `publish` after `happyskills fork`. The CLI now reads `manifest.forked_from.repo` (an `owner/name` string written by the fork command), splits it into `{ workspace, name }`, and includes it in the push payload across all three transport modes: direct push, archive upload, and per-file presigned upload. The API resolves the parent and persists `repos.forked_from`, which feeds the duplicate-detection heuristic (forks-of-active-parents are ineligible to be elected source-of-truth, and the publish-time `duplicates` warning is suppressed when the only match is the fork's parent). Subsequent publishes of an existing repo are unaffected — the field is honored only on the create-repo path. Requires API ≥ 2.4.0.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Fix `publish` crashing with a `ReferenceError`/TDZ on `visibility` when validation runs before the spinner update — the `const visibility = args.flags.public ? 'public' : 'private'` declaration was below its first use. Move the declaration ahead of all reads.
|
|
24
|
+
- Fix `validate_manifest` rejecting kit names like `_kit-foo` ("Invalid name … Must start with a letter or number"). Kits are required by `init --kit` and `validate_skill_json` to start with `_kit-`, so the leading-character rule is now carved out for `manifest.type === 'kit'` via a separate `KIT_NAME_PATTERN`. Bumping a kit no longer fails its own pre-publish manifest validation.
|
|
25
|
+
|
|
10
26
|
## [0.39.1] - 2026-05-01
|
|
11
27
|
|
|
12
28
|
### Fixed
|
package/package.json
CHANGED
package/src/api/push.js
CHANGED
|
@@ -13,7 +13,7 @@ const estimate_payload_size = (files) => {
|
|
|
13
13
|
return size
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
|
|
16
|
+
const archive_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress) =>
|
|
17
17
|
catch_errors('Archive push failed', async () => {
|
|
18
18
|
const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
|
|
19
19
|
|
|
@@ -28,6 +28,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
|
|
|
28
28
|
archive: true, archive_sha: archive.sha, archive_size: archive.size
|
|
29
29
|
}
|
|
30
30
|
if (parent_shas) init_body.parent_shas = parent_shas
|
|
31
|
+
if (forked_from) init_body.forked_from = forked_from
|
|
31
32
|
const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
|
|
32
33
|
if (init_err) throw e('Upload initiation failed', init_err)
|
|
33
34
|
|
|
@@ -44,6 +45,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
|
|
|
44
45
|
version, message, files: file_meta, base_commit, force
|
|
45
46
|
}
|
|
46
47
|
if (parent_shas) complete_body.parent_shas = parent_shas
|
|
48
|
+
if (forked_from) complete_body.forked_from = forked_from
|
|
47
49
|
const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
|
|
48
50
|
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
49
51
|
|
|
@@ -62,7 +64,7 @@ const archive_push = (owner, repo, { version, message, files, visibility, base_c
|
|
|
62
64
|
}
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress) =>
|
|
67
|
+
const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress) =>
|
|
66
68
|
catch_errors('Smart push failed', async () => {
|
|
67
69
|
const payload_size = estimate_payload_size(files)
|
|
68
70
|
|
|
@@ -70,6 +72,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
70
72
|
// Small payload — use direct push
|
|
71
73
|
const body = { version, message, files, visibility, base_commit, force }
|
|
72
74
|
if (parent_shas) body.parent_shas = parent_shas
|
|
75
|
+
if (forked_from) body.forked_from = forked_from
|
|
73
76
|
const [err, data] = await repos_api.push(owner, repo, body)
|
|
74
77
|
if (err) throw e('Direct push failed', err)
|
|
75
78
|
return data
|
|
@@ -77,7 +80,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
77
80
|
|
|
78
81
|
// Large payload — prefer archive upload if source_dir is available
|
|
79
82
|
if (source_dir) {
|
|
80
|
-
return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, source_dir }, on_progress)
|
|
83
|
+
return await archive_push(owner, repo, { version, message, files, visibility, base_commit, force, parent_shas, forked_from, source_dir }, on_progress)
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
// Fallback: per-file presigned upload
|
|
@@ -86,6 +89,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
86
89
|
// Step 1: Initiate
|
|
87
90
|
const init_body = { version, message, files: file_meta, visibility, base_commit, force }
|
|
88
91
|
if (parent_shas) init_body.parent_shas = parent_shas
|
|
92
|
+
if (forked_from) init_body.forked_from = forked_from
|
|
89
93
|
const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
|
|
90
94
|
if (init_err) throw e('Upload initiation failed', init_err)
|
|
91
95
|
|
|
@@ -111,6 +115,7 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
|
|
|
111
115
|
// Step 3: Complete
|
|
112
116
|
const complete_body = { upload_id, version, message, files: file_meta, base_commit, force }
|
|
113
117
|
if (parent_shas) complete_body.parent_shas = parent_shas
|
|
118
|
+
if (forked_from) complete_body.forked_from = forked_from
|
|
114
119
|
const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
|
|
115
120
|
if (complete_err) throw e('Upload completion failed', complete_err)
|
|
116
121
|
|
package/src/api/repos.js
CHANGED
|
@@ -80,6 +80,13 @@ const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/$
|
|
|
80
80
|
return data
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
const get_tree = (owner, repo, ref) => catch_errors(`Get tree for ${owner}/${repo} failed`, async () => {
|
|
84
|
+
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ''
|
|
85
|
+
const [errors, data] = await client.get(`/repos/${owner}/${repo}/tree${qs}`)
|
|
86
|
+
if (errors) throw errors[errors.length - 1]
|
|
87
|
+
return data
|
|
88
|
+
})
|
|
89
|
+
|
|
83
90
|
const semantic_search = (query, options = {}) => catch_errors('Semantic search failed', async () => {
|
|
84
91
|
const body = {
|
|
85
92
|
q: query,
|
|
@@ -98,4 +105,4 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
|
|
|
98
105
|
return data
|
|
99
106
|
})
|
|
100
107
|
|
|
101
|
-
module.exports = { search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob }
|
|
108
|
+
module.exports = { search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob, get_tree }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { get_refs, get_tree, get_blob } = require('../api/repos')
|
|
3
|
+
const { print_help, print_json, print_info } = require('../ui/output')
|
|
4
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
5
|
+
const { EXIT_CODES } = require('../constants')
|
|
6
|
+
const { extract_version, sort_refs } = require('./versions')
|
|
7
|
+
|
|
8
|
+
const HELP_TEXT = `Usage: happyskills changelog <owner/name> [options]
|
|
9
|
+
|
|
10
|
+
Print a skill's CHANGELOG.md as it exists at the latest version (or at a
|
|
11
|
+
specific version with --version).
|
|
12
|
+
|
|
13
|
+
If the skill has no CHANGELOG.md file, a synthesized changelog is generated
|
|
14
|
+
from the registry's release messages.
|
|
15
|
+
|
|
16
|
+
Arguments:
|
|
17
|
+
owner/name Skill identifier (e.g., acme/deploy-aws)
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--version <ver> Read the changelog at this specific version (default: latest)
|
|
21
|
+
--json Output as JSON
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
happyskills changelog acme/deploy-aws
|
|
25
|
+
happyskills changelog acme/deploy-aws --version 1.2.0
|
|
26
|
+
happyskills changelog acme/deploy-aws --json`
|
|
27
|
+
|
|
28
|
+
const CHANGELOG_FILENAMES = ['CHANGELOG.md', 'changelog.md', 'CHANGELOG', 'Changelog.md']
|
|
29
|
+
|
|
30
|
+
const find_changelog_entry = (entries) => {
|
|
31
|
+
if (!Array.isArray(entries)) return null
|
|
32
|
+
for (const name of CHANGELOG_FILENAMES) {
|
|
33
|
+
const hit = entries.find(e => e.path === name)
|
|
34
|
+
if (hit) return hit
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const synthesize_from_refs = (refs, skill) => {
|
|
40
|
+
const sorted = sort_refs(refs)
|
|
41
|
+
const lines = [`# Changelog — ${skill}`, '']
|
|
42
|
+
lines.push('_This skill has no CHANGELOG.md file. The entries below are synthesized from registry release messages._', '')
|
|
43
|
+
for (const r of sorted) {
|
|
44
|
+
const version = extract_version(r.name)
|
|
45
|
+
const date = r.created_at ? r.created_at.slice(0, 10) : ''
|
|
46
|
+
lines.push(`## ${version}${date ? ` — ${date}` : ''}`)
|
|
47
|
+
lines.push('')
|
|
48
|
+
lines.push(r.message || '_(no release message)_')
|
|
49
|
+
lines.push('')
|
|
50
|
+
}
|
|
51
|
+
return lines.join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalize_ref = (version_str) => {
|
|
55
|
+
if (!version_str) return null
|
|
56
|
+
if (version_str.startsWith('refs/')) return version_str
|
|
57
|
+
return `refs/tags/v${version_str.replace(/^v/, '')}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const run = (args) => catch_errors('Changelog failed', async () => {
|
|
61
|
+
if (args.flags._show_help) {
|
|
62
|
+
print_help(HELP_TEXT)
|
|
63
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const skill = args._[0]
|
|
67
|
+
if (!skill) {
|
|
68
|
+
throw new UsageError('Please specify a skill (e.g., happyskills changelog acme/deploy-aws).')
|
|
69
|
+
}
|
|
70
|
+
if (!skill.includes('/')) {
|
|
71
|
+
throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const [owner, name] = skill.split('/')
|
|
75
|
+
const requested_version = args.flags.version && args.flags.version !== true
|
|
76
|
+
? String(args.flags.version)
|
|
77
|
+
: null
|
|
78
|
+
const requested_ref = normalize_ref(requested_version)
|
|
79
|
+
|
|
80
|
+
const [tree_err, tree] = await get_tree(owner, name, requested_ref)
|
|
81
|
+
if (tree_err) throw tree_err[tree_err.length - 1]
|
|
82
|
+
|
|
83
|
+
const resolved_ref = tree.ref
|
|
84
|
+
const resolved_version = extract_version(resolved_ref)
|
|
85
|
+
const resolved_commit = tree.commit
|
|
86
|
+
|
|
87
|
+
const entry = find_changelog_entry(tree.entries)
|
|
88
|
+
|
|
89
|
+
let content
|
|
90
|
+
let synthesized = false
|
|
91
|
+
|
|
92
|
+
if (entry) {
|
|
93
|
+
const [blob_err, blob] = await get_blob(owner, name, entry.sha)
|
|
94
|
+
if (blob_err) throw blob_err[blob_err.length - 1]
|
|
95
|
+
content = Buffer.from(blob.content || '', 'base64').toString('utf8')
|
|
96
|
+
} else {
|
|
97
|
+
const [refs_err, refs] = await get_refs(owner, name)
|
|
98
|
+
if (refs_err) throw refs_err[refs_err.length - 1]
|
|
99
|
+
content = synthesize_from_refs(refs, skill)
|
|
100
|
+
synthesized = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (args.flags.json) {
|
|
104
|
+
print_json({
|
|
105
|
+
data: {
|
|
106
|
+
skill,
|
|
107
|
+
version: resolved_version,
|
|
108
|
+
ref: resolved_ref,
|
|
109
|
+
commit: resolved_commit,
|
|
110
|
+
synthesized,
|
|
111
|
+
content
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (synthesized) {
|
|
118
|
+
print_info(`No CHANGELOG.md in ${skill}@${resolved_version} — showing release messages from the registry.`)
|
|
119
|
+
process.stdout.write('\n')
|
|
120
|
+
}
|
|
121
|
+
process.stdout.write(content)
|
|
122
|
+
if (!content.endsWith('\n')) process.stdout.write('\n')
|
|
123
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
124
|
+
|
|
125
|
+
module.exports = { run, find_changelog_entry, synthesize_from_refs, normalize_ref }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { find_changelog_entry, synthesize_from_refs, normalize_ref } = require('./changelog')
|
|
5
|
+
|
|
6
|
+
describe('changelog — find_changelog_entry', () => {
|
|
7
|
+
it('finds CHANGELOG.md (canonical case)', () => {
|
|
8
|
+
const entries = [
|
|
9
|
+
{ path: 'SKILL.md', sha: 'a' },
|
|
10
|
+
{ path: 'CHANGELOG.md', sha: 'b' }
|
|
11
|
+
]
|
|
12
|
+
assert.strictEqual(find_changelog_entry(entries).sha, 'b')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('falls back to lowercase variant', () => {
|
|
16
|
+
const entries = [{ path: 'changelog.md', sha: 'c' }]
|
|
17
|
+
assert.strictEqual(find_changelog_entry(entries).sha, 'c')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns null when no changelog file present', () => {
|
|
21
|
+
const entries = [{ path: 'SKILL.md', sha: 'a' }]
|
|
22
|
+
assert.strictEqual(find_changelog_entry(entries), null)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('handles non-array input safely', () => {
|
|
26
|
+
assert.strictEqual(find_changelog_entry(null), null)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('changelog — normalize_ref', () => {
|
|
31
|
+
it('returns null for empty input', () => {
|
|
32
|
+
assert.strictEqual(normalize_ref(null), null)
|
|
33
|
+
assert.strictEqual(normalize_ref(''), null)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('passes through full refs', () => {
|
|
37
|
+
assert.strictEqual(normalize_ref('refs/tags/v1.2.0'), 'refs/tags/v1.2.0')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('wraps plain semver into a tag ref', () => {
|
|
41
|
+
assert.strictEqual(normalize_ref('1.2.0'), 'refs/tags/v1.2.0')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('strips leading v before re-wrapping', () => {
|
|
45
|
+
assert.strictEqual(normalize_ref('v1.2.0'), 'refs/tags/v1.2.0')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('changelog — synthesize_from_refs', () => {
|
|
50
|
+
it('produces sections newest first with version + date headings', () => {
|
|
51
|
+
const refs = [
|
|
52
|
+
{ name: 'refs/tags/v1.0.0', created_at: '2026-01-01T00:00:00Z', message: 'Initial release' },
|
|
53
|
+
{ name: 'refs/tags/v1.1.0', created_at: '2026-02-01T00:00:00Z', message: 'Add feature X' }
|
|
54
|
+
]
|
|
55
|
+
const out = synthesize_from_refs(refs, 'acme/foo')
|
|
56
|
+
assert.ok(out.includes('# Changelog — acme/foo'))
|
|
57
|
+
assert.ok(out.includes('## 1.1.0 — 2026-02-01'))
|
|
58
|
+
assert.ok(out.includes('Add feature X'))
|
|
59
|
+
const idx_new = out.indexOf('## 1.1.0')
|
|
60
|
+
const idx_old = out.indexOf('## 1.0.0')
|
|
61
|
+
assert.ok(idx_new < idx_old, 'newest version should come first')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('falls back to placeholder when message is empty', () => {
|
|
65
|
+
const refs = [{ name: 'refs/tags/v1.0.0', created_at: null, message: null }]
|
|
66
|
+
const out = synthesize_from_refs(refs, 'acme/foo')
|
|
67
|
+
assert.ok(out.includes('_(no release message)_'))
|
|
68
|
+
})
|
|
69
|
+
})
|
package/src/commands/publish.js
CHANGED
|
@@ -135,6 +135,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
135
135
|
|
|
136
136
|
const spinner = create_spinner('Preparing to publish...')
|
|
137
137
|
|
|
138
|
+
const visibility = args.flags.public ? 'public' : 'private'
|
|
139
|
+
|
|
138
140
|
let owner = args.flags.workspace
|
|
139
141
|
if (!owner) {
|
|
140
142
|
const [, resolved_owner] = await resolve_skill_owner(skill_name)
|
|
@@ -221,7 +223,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
221
223
|
spinner.update(`Uploading files (${completed}/${total})...`)
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
|
-
const visibility = args.flags.public ? 'public' : 'private'
|
|
225
226
|
const push_options = {
|
|
226
227
|
version: manifest.version,
|
|
227
228
|
message: `Release ${manifest.version}`,
|
|
@@ -234,6 +235,15 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
234
235
|
if (merge_parents && !force) {
|
|
235
236
|
push_options.parent_shas = merge_parents
|
|
236
237
|
}
|
|
238
|
+
|
|
239
|
+
// Propagate fork relationship to the server on the first publish.
|
|
240
|
+
// Only the create-repo path uses this; existing repos already have forked_from set.
|
|
241
|
+
if (manifest.forked_from && typeof manifest.forked_from.repo === 'string' && manifest.forked_from.repo.includes('/')) {
|
|
242
|
+
const [parent_workspace, parent_name] = manifest.forked_from.repo.split('/')
|
|
243
|
+
if (parent_workspace && parent_name) {
|
|
244
|
+
push_options.forked_from = { workspace: parent_workspace, name: parent_name }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
237
247
|
const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, push_options, on_progress)
|
|
238
248
|
if (push_err) {
|
|
239
249
|
const last = push_err[push_err.length - 1]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { describe, it } = require('node:test')
|
|
2
|
+
const assert = require('node:assert')
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
|
|
6
|
+
describe('publish.js — Bug 1 regression: visibility temporal-dead-zone', () => {
|
|
7
|
+
const src = fs.readFileSync(path.join(__dirname, 'publish.js'), 'utf8')
|
|
8
|
+
|
|
9
|
+
it('declares `visibility` before passing it to validate_dependencies', () => {
|
|
10
|
+
const decl_idx = src.search(/const\s+visibility\s*=\s*args\.flags\.public/)
|
|
11
|
+
const use_idx = src.search(/validate_dependencies\([\s\S]*?visibility/)
|
|
12
|
+
assert.notStrictEqual(decl_idx, -1, '`const visibility = args.flags.public ? ...` declaration not found')
|
|
13
|
+
assert.notStrictEqual(use_idx, -1, '`validate_dependencies(... visibility ...)` call not found')
|
|
14
|
+
assert.ok(
|
|
15
|
+
decl_idx < use_idx,
|
|
16
|
+
`visibility must be declared (idx ${decl_idx}) before it is read inside validate_dependencies (idx ${use_idx}). ` +
|
|
17
|
+
'The current ordering causes `ReferenceError: Cannot access \'visibility\' before initialization` ' +
|
|
18
|
+
'whenever skill.json declares one or more dependencies.'
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { get_refs } = require('../api/repos')
|
|
3
|
+
const { print_help, print_table, print_json, print_info } = require('../ui/output')
|
|
4
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
5
|
+
const { EXIT_CODES } = require('../constants')
|
|
6
|
+
|
|
7
|
+
const HELP_TEXT = `Usage: happyskills versions <owner/name> [options]
|
|
8
|
+
|
|
9
|
+
List all published versions of a skill, newest first.
|
|
10
|
+
|
|
11
|
+
Arguments:
|
|
12
|
+
owner/name Skill identifier (e.g., acme/deploy-aws)
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
--limit <n> Limit the number of versions shown (default: all)
|
|
16
|
+
--json Output as JSON
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
happyskills versions acme/deploy-aws
|
|
20
|
+
happyskills versions acme/deploy-aws --limit 20
|
|
21
|
+
happyskills versions acme/deploy-aws --json`
|
|
22
|
+
|
|
23
|
+
const extract_version = (ref_name) => {
|
|
24
|
+
const m = (ref_name || '').match(/^refs\/tags\/v?(.+)$/)
|
|
25
|
+
return m ? m[1] : ref_name
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sort_refs = (refs) => {
|
|
29
|
+
const copy = [...(refs || [])]
|
|
30
|
+
copy.sort((a, b) => {
|
|
31
|
+
const ta = a.created_at ? Date.parse(a.created_at) : 0
|
|
32
|
+
const tb = b.created_at ? Date.parse(b.created_at) : 0
|
|
33
|
+
return tb - ta
|
|
34
|
+
})
|
|
35
|
+
return copy
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const run = (args) => catch_errors('Versions failed', async () => {
|
|
39
|
+
if (args.flags._show_help) {
|
|
40
|
+
print_help(HELP_TEXT)
|
|
41
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const skill = args._[0]
|
|
45
|
+
if (!skill) {
|
|
46
|
+
throw new UsageError('Please specify a skill (e.g., happyskills versions acme/deploy-aws).')
|
|
47
|
+
}
|
|
48
|
+
if (!skill.includes('/')) {
|
|
49
|
+
throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const limit = args.flags.limit ? parseInt(args.flags.limit, 10) : null
|
|
53
|
+
if (args.flags.limit && (Number.isNaN(limit) || limit < 1)) {
|
|
54
|
+
throw new UsageError('--limit must be a positive integer.')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [owner, name] = skill.split('/')
|
|
58
|
+
|
|
59
|
+
const [errors, refs] = await get_refs(owner, name)
|
|
60
|
+
if (errors) throw errors[errors.length - 1]
|
|
61
|
+
|
|
62
|
+
const sorted = sort_refs(refs)
|
|
63
|
+
const limited = limit ? sorted.slice(0, limit) : sorted
|
|
64
|
+
|
|
65
|
+
const versions = limited.map(r => ({
|
|
66
|
+
version: extract_version(r.name),
|
|
67
|
+
ref: r.name,
|
|
68
|
+
commit: r.sha,
|
|
69
|
+
message: r.message || '',
|
|
70
|
+
published_at: r.created_at || null
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
if (args.flags.json) {
|
|
74
|
+
print_json({ data: { skill, count: versions.length, versions } })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (versions.length === 0) {
|
|
79
|
+
print_info(`No versions published for ${skill}.`)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rows = versions.map(v => [
|
|
84
|
+
v.version,
|
|
85
|
+
v.published_at ? v.published_at.slice(0, 10) : '-',
|
|
86
|
+
(v.commit || '').slice(0, 7),
|
|
87
|
+
v.message
|
|
88
|
+
])
|
|
89
|
+
print_table(['VERSION', 'PUBLISHED', 'COMMIT', 'MESSAGE'], rows)
|
|
90
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
91
|
+
|
|
92
|
+
module.exports = { run, extract_version, sort_refs }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const { extract_version, sort_refs } = require('./versions')
|
|
5
|
+
|
|
6
|
+
describe('versions — extract_version', () => {
|
|
7
|
+
it('strips refs/tags/v prefix', () => {
|
|
8
|
+
assert.strictEqual(extract_version('refs/tags/v1.2.0'), '1.2.0')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('handles refs without leading v', () => {
|
|
12
|
+
assert.strictEqual(extract_version('refs/tags/1.2.0'), '1.2.0')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('returns the input when not a tag ref', () => {
|
|
16
|
+
assert.strictEqual(extract_version('main'), 'main')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns input for null/undefined safely', () => {
|
|
20
|
+
assert.strictEqual(extract_version(''), '')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('versions — sort_refs', () => {
|
|
25
|
+
it('sorts newest first by created_at', () => {
|
|
26
|
+
const refs = [
|
|
27
|
+
{ name: 'refs/tags/v1.0.0', created_at: '2026-01-01T00:00:00Z' },
|
|
28
|
+
{ name: 'refs/tags/v1.2.0', created_at: '2026-03-01T00:00:00Z' },
|
|
29
|
+
{ name: 'refs/tags/v1.1.0', created_at: '2026-02-01T00:00:00Z' }
|
|
30
|
+
]
|
|
31
|
+
const sorted = sort_refs(refs)
|
|
32
|
+
assert.deepStrictEqual(sorted.map(r => r.name), [
|
|
33
|
+
'refs/tags/v1.2.0',
|
|
34
|
+
'refs/tags/v1.1.0',
|
|
35
|
+
'refs/tags/v1.0.0'
|
|
36
|
+
])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('handles empty/null input', () => {
|
|
40
|
+
assert.deepStrictEqual(sort_refs([]), [])
|
|
41
|
+
assert.deepStrictEqual(sort_refs(null), [])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not mutate input array', () => {
|
|
45
|
+
const refs = [
|
|
46
|
+
{ name: 'a', created_at: '2026-01-01T00:00:00Z' },
|
|
47
|
+
{ name: 'b', created_at: '2026-02-01T00:00:00Z' }
|
|
48
|
+
]
|
|
49
|
+
const original = [...refs]
|
|
50
|
+
sort_refs(refs)
|
|
51
|
+
assert.deepStrictEqual(refs, original)
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/constants.js
CHANGED
package/src/index.js
CHANGED
|
@@ -91,6 +91,8 @@ Commands:
|
|
|
91
91
|
visibility <owner/skill> Check or set visibility (alias: vis)
|
|
92
92
|
list List installed skills (alias: ls)
|
|
93
93
|
search <query> Search the registry (alias: s)
|
|
94
|
+
versions <owner/skill> List all published versions of a skill
|
|
95
|
+
changelog <owner/skill> Print a skill's CHANGELOG.md
|
|
94
96
|
check [owner/skill] Check for available updates
|
|
95
97
|
status [owner/skill] Show divergence status (alias: st)
|
|
96
98
|
pull <owner/skill> Pull remote changes and merge
|
|
@@ -947,3 +947,68 @@ describe('list — enabled column', () => {
|
|
|
947
947
|
}
|
|
948
948
|
})
|
|
949
949
|
})
|
|
950
|
+
|
|
951
|
+
// ─── versions / changelog commands ────────────────────────────────────────────
|
|
952
|
+
|
|
953
|
+
describe('CLI — versions command', () => {
|
|
954
|
+
it('versions --help exits 0 and shows usage', () => {
|
|
955
|
+
const { stdout, code } = run(['versions', '--help'])
|
|
956
|
+
assert.strictEqual(code, 0)
|
|
957
|
+
assert.ok(stdout.includes('Arguments:'))
|
|
958
|
+
assert.ok(stdout.includes('Options:'))
|
|
959
|
+
assert.ok(stdout.includes('--limit'))
|
|
960
|
+
})
|
|
961
|
+
|
|
962
|
+
it('versions without arguments exits with usage error', () => {
|
|
963
|
+
const { code, stderr } = run(['versions'])
|
|
964
|
+
assert.strictEqual(code, 2)
|
|
965
|
+
assert.ok(stderr.includes('specify a skill'))
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
it('versions with no slash exits with usage error', () => {
|
|
969
|
+
const { code, stderr } = run(['versions', 'deploy-aws'])
|
|
970
|
+
assert.strictEqual(code, 2)
|
|
971
|
+
assert.ok(stderr.includes('owner/name format'))
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
it('versions with bad --limit exits with usage error', () => {
|
|
975
|
+
const { code, stderr } = run(['versions', 'acme/deploy-aws', '--limit', 'abc'])
|
|
976
|
+
assert.strictEqual(code, 2)
|
|
977
|
+
assert.ok(stderr.toLowerCase().includes('limit'))
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('versions acme/deploy-aws --json fails with NETWORK_ERROR (no server)', () => {
|
|
981
|
+
const { stdout, code } = run(['versions', 'acme/deploy-aws', '--json'])
|
|
982
|
+
assert.strictEqual(code, 4)
|
|
983
|
+
const out = parse_json_output(stdout, 'versions --json network failure')
|
|
984
|
+
assert.strictEqual(out.error.code, 'NETWORK_ERROR')
|
|
985
|
+
})
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
describe('CLI — changelog command', () => {
|
|
989
|
+
it('changelog --help exits 0 and shows usage', () => {
|
|
990
|
+
const { stdout, code } = run(['changelog', '--help'])
|
|
991
|
+
assert.strictEqual(code, 0)
|
|
992
|
+
assert.ok(stdout.includes('Arguments:'))
|
|
993
|
+
assert.ok(stdout.includes('--version'))
|
|
994
|
+
})
|
|
995
|
+
|
|
996
|
+
it('changelog without arguments exits with usage error', () => {
|
|
997
|
+
const { code, stderr } = run(['changelog'])
|
|
998
|
+
assert.strictEqual(code, 2)
|
|
999
|
+
assert.ok(stderr.includes('specify a skill'))
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
it('changelog with no slash exits with usage error', () => {
|
|
1003
|
+
const { code, stderr } = run(['changelog', 'deploy-aws'])
|
|
1004
|
+
assert.strictEqual(code, 2)
|
|
1005
|
+
assert.ok(stderr.includes('owner/name format'))
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('changelog acme/deploy-aws --json fails with NETWORK_ERROR (no server)', () => {
|
|
1009
|
+
const { stdout, code } = run(['changelog', 'acme/deploy-aws', '--json'])
|
|
1010
|
+
assert.strictEqual(code, 4)
|
|
1011
|
+
const out = parse_json_output(stdout, 'changelog --json network failure')
|
|
1012
|
+
assert.strictEqual(out.error.code, 'NETWORK_ERROR')
|
|
1013
|
+
})
|
|
1014
|
+
})
|
|
@@ -2,6 +2,9 @@ const { valid } = require('../utils/semver')
|
|
|
2
2
|
|
|
3
3
|
const REQUIRED_FIELDS = ['name', 'version']
|
|
4
4
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
5
|
+
// Kits are required to start with `_kit-` (enforced by `init --kit` and
|
|
6
|
+
// validate_skill_json). Carve out the leading-character rule for them.
|
|
7
|
+
const KIT_NAME_PATTERN = /^_kit-[a-z0-9][a-z0-9-]*$/
|
|
5
8
|
|
|
6
9
|
const validate_manifest = (manifest) => {
|
|
7
10
|
const errors = []
|
|
@@ -12,7 +15,8 @@ const validate_manifest = (manifest) => {
|
|
|
12
15
|
}
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
const name_pattern = manifest.type === 'kit' ? KIT_NAME_PATTERN : NAME_PATTERN
|
|
19
|
+
if (manifest.name && !name_pattern.test(manifest.name)) {
|
|
16
20
|
errors.push(`Invalid name "${manifest.name}". Use lowercase letters, numbers, hyphens, and underscores. Must start with a letter or number.`)
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -98,4 +98,26 @@ describe('validate_manifest', () => {
|
|
|
98
98
|
const result = validate_manifest({ name: 'my-skill', version: '1.0.0' })
|
|
99
99
|
assert.strictEqual(result.valid, true)
|
|
100
100
|
})
|
|
101
|
+
|
|
102
|
+
// Bug 2 regression — kit names start with `_kit-` (enforced by `init --kit` and
|
|
103
|
+
// `validate_skill_json` for type==='kit'). The shared `validate_manifest` used
|
|
104
|
+
// by `bump` must accept this convention or the standard release workflow breaks.
|
|
105
|
+
describe('kit name convention', () => {
|
|
106
|
+
it('accepts a kit name beginning with _kit- when type is "kit"', () => {
|
|
107
|
+
const result = validate_manifest({ name: '_kit-mykit', version: '1.0.0', type: 'kit' })
|
|
108
|
+
assert.strictEqual(result.valid, true, `expected valid, got errors: ${result.errors.join(', ')}`)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('still rejects a non-kit manifest whose name starts with _', () => {
|
|
112
|
+
const result = validate_manifest({ name: '_oops', version: '1.0.0' })
|
|
113
|
+
assert.strictEqual(result.valid, false)
|
|
114
|
+
assert.ok(result.errors.some(e => e.includes('Invalid name')))
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('still rejects a non-kit manifest whose name starts with _ even if type=skill', () => {
|
|
118
|
+
const result = validate_manifest({ name: '_oops', version: '1.0.0', type: 'skill' })
|
|
119
|
+
assert.strictEqual(result.valid, false)
|
|
120
|
+
assert.ok(result.errors.some(e => e.includes('Invalid name')))
|
|
121
|
+
})
|
|
122
|
+
})
|
|
101
123
|
})
|
|
@@ -12,6 +12,9 @@ const FORBIDDEN_CHARS = {
|
|
|
12
12
|
|
|
13
13
|
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
|
14
14
|
const PLACEHOLDER_DESC = 'Describe what this skill does and when to invoke it'
|
|
15
|
+
const DESC_SOFT_CAP = 250
|
|
16
|
+
const DESC_TARGET_MIN = 80
|
|
17
|
+
const DESC_TARGET_MAX = 180
|
|
15
18
|
|
|
16
19
|
const result = (field, rule, severity, message, value) => ({
|
|
17
20
|
file: SKILL_MD, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
@@ -89,6 +92,20 @@ const validate_description = (fm) => {
|
|
|
89
92
|
})
|
|
90
93
|
} else {
|
|
91
94
|
results.push(result('description', 'max_length', 'pass', `Description: ${desc.length} chars (max 1024)`))
|
|
95
|
+
|
|
96
|
+
if (desc.length > DESC_SOFT_CAP) {
|
|
97
|
+
results.push({
|
|
98
|
+
...result('description', 'soft_cap', 'warning', `Description is ${desc.length} chars (target: ${DESC_TARGET_MIN}-${DESC_TARGET_MAX}, soft cap: ${DESC_SOFT_CAP}). Above ${DESC_SOFT_CAP} chars usually signals a mega-skill — apply the Suite Pattern to decompose it into a core skill plus focused satellites. Run "npx happyskills audit <name>" or ask your agent "audit this skill" — happyskills-design will walk you through the Suite Decomposition Workflow.`, desc),
|
|
99
|
+
recommendations: [
|
|
100
|
+
'CANONICAL — Apply the Suite Pattern: decompose the skill into a core entry-point skill plus satellite skills, each owning one orthogonal verb cluster, bundled via the core skill.json dependencies. This is the answer once a description crosses the soft cap. happyskills-design implements the Suite Decomposition Workflow end-to-end — invoke it with "audit this skill" or "decompose this mega-skill".',
|
|
101
|
+
'Alternative — Compress the description first (AUDIT/LOSSLESS/LOSSY procedure in happyskills-design references/skill-authoring.md). Buys time, but compression alone will not keep up as the API surface grows.',
|
|
102
|
+
'Alternative — Hybrid umbrella+satellites: keep one main skill for high-frequency operations, extract specialized domains into satellites.',
|
|
103
|
+
'For the canonical Suite Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/suite-pattern.md, or docs/cli-skill.md in the HappySkills repo.'
|
|
104
|
+
]
|
|
105
|
+
})
|
|
106
|
+
} else {
|
|
107
|
+
results.push(result('description', 'soft_cap', 'pass', `Description: ${desc.length} chars (soft cap ${DESC_SOFT_CAP})`))
|
|
108
|
+
}
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
if (desc === PLACEHOLDER_DESC) {
|
|
@@ -145,6 +145,40 @@ describe('validate_skill_md — description', () => {
|
|
|
145
145
|
const check = data.results.find(r => r.rule === 'no_forbidden_characters')
|
|
146
146
|
assert.strictEqual(check.severity, 'pass')
|
|
147
147
|
})
|
|
148
|
+
|
|
149
|
+
it('warns when description exceeds the 250-char soft cap (still under 1024 hard cap)', async () => {
|
|
150
|
+
const desc = 'a'.repeat(300)
|
|
151
|
+
write_skill_md(tmp, { name: 'my-skill', description: desc })
|
|
152
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
153
|
+
assert.ifError(err)
|
|
154
|
+
const max_length = data.results.find(r => r.field === 'description' && r.rule === 'max_length')
|
|
155
|
+
assert.strictEqual(max_length.severity, 'pass')
|
|
156
|
+
const soft_cap = data.results.find(r => r.field === 'description' && r.rule === 'soft_cap')
|
|
157
|
+
assert.strictEqual(soft_cap.severity, 'warning')
|
|
158
|
+
assert.ok(soft_cap.message.includes('300 chars'))
|
|
159
|
+
assert.ok(Array.isArray(soft_cap.recommendations))
|
|
160
|
+
assert.ok(soft_cap.recommendations.length > 0)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('passes the soft cap when description is at or under 250 chars', async () => {
|
|
164
|
+
const desc = 'a'.repeat(250)
|
|
165
|
+
write_skill_md(tmp, { name: 'my-skill', description: desc })
|
|
166
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
167
|
+
assert.ifError(err)
|
|
168
|
+
const soft_cap = data.results.find(r => r.field === 'description' && r.rule === 'soft_cap')
|
|
169
|
+
assert.strictEqual(soft_cap.severity, 'pass')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('does not emit a soft_cap warning when description is over the 1024 hard cap (max_length error dwarfs it)', async () => {
|
|
173
|
+
const desc = 'a'.repeat(1100)
|
|
174
|
+
write_skill_md(tmp, { name: 'my-skill', description: desc })
|
|
175
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
176
|
+
assert.ifError(err)
|
|
177
|
+
const max_length = data.results.find(r => r.field === 'description' && r.rule === 'max_length')
|
|
178
|
+
assert.strictEqual(max_length.severity, 'error')
|
|
179
|
+
const soft_cap = data.results.find(r => r.field === 'description' && r.rule === 'soft_cap')
|
|
180
|
+
assert.strictEqual(soft_cap, undefined)
|
|
181
|
+
})
|
|
148
182
|
})
|
|
149
183
|
|
|
150
184
|
describe('validate_skill_md — optional fields', () => {
|