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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.39.1",
3
+ "version": "0.41.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
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
+ })
@@ -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
@@ -70,7 +70,9 @@ const COMMANDS = [
70
70
  'groups',
71
71
  'access',
72
72
  'enable',
73
- 'disable'
73
+ 'disable',
74
+ 'versions',
75
+ 'changelog'
74
76
  ]
75
77
 
76
78
  module.exports = {
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
- if (manifest.name && !NAME_PATTERN.test(manifest.name)) {
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', () => {