happyskills 0.50.0 → 0.51.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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.51.0] - 2026-05-25
11
+
12
+ ### Added
13
+
14
+ - **`list --json` now splits unclaimed on-disk skills into `data.drafts[]` and `data.external[]`** (`cli/src/commands/list.js`, `cli/src/utils/skill_scanner.js`). Drafts are skills scaffolded by `init` — they have a HappySkills-shaped `skill.json` (non-empty `name`, valid-semver `version`, `type` of `"skill"` or `"kit"`) and a `SKILL.md`, but no entry in `skills-lock.json` yet because the user hasn't published. External skills are genuinely foreign — they have a `SKILL.md` but no manifest or a foreign-shaped one, and they require `convert` before they can be published. Each draft entry includes `{ name, description, version, type }`; external entries keep the existing `{ name, description }` shape. Human output gains a `draft (unpublished)` row label and a closing hint pointing at `happyskills release` so the principal sees what to do next without having to read the docs.
15
+ - **`is_happyskills_shaped_manifest()` exported from `cli/src/utils/skill_scanner.js`** — small predicate that other call sites (e.g. `happyskills-publish`'s pre-flight) can use to classify a found-on-disk skill the same way `list` does.
16
+
17
+ ### Rationale
18
+
19
+ Spec 260522-02: when a user scaffolded a skill with `npx happyskills init` and immediately asked an LLM agent to "publish it", the previous `list --json` output reported the skill under `data.external[]` — the same bucket used for genuinely-foreign skills. Routing skills (notably `happyskills-publish`) read this as "external, must `convert` first", inserted a redundant `convert` step into the happy path, and surfaced the words `external` and `convert` to a user who had created their skill with the official tool five minutes earlier. That violated the mission's principal-surface promise of "no unexplained jargon" and "consistency over novelty". The CLI fix is a data-layer split (`data.drafts[]` vs `data.external[]`) so every consumer — routing skills, human output, future tooling — can tell the two states apart at the source. The companion routing-skill fix lands in `happyskills-publish@0.5.0` (in the same monorepo change set), which now reads `data.drafts` and routes drafts straight to `release` without ever mentioning `convert` or `external` to the user.
20
+
10
21
  ## [0.50.0] - 2026-05-24
11
22
 
12
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.50.0",
3
+ "version": "0.51.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)",
@@ -133,7 +133,7 @@ const run = (args) => catch_errors('Init failed', async () => {
133
133
  print_info(`Linked to: ${agents_result.agents.map(a => a.display_name).join(', ')}`)
134
134
  }
135
135
  console.log()
136
- print_hint(`Edit these files, then run ${code('happyskills publish')} to share.`)
136
+ print_hint(`Edit these files, then run ${code(`happyskills release ${final_name} --workspace <slug>`)} to publish.`)
137
137
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
138
138
 
139
139
  module.exports = { run }
@@ -51,16 +51,23 @@ const run = (args) => catch_errors('List failed', async () => {
51
51
 
52
52
  const [, disk_skills] = await scan_skills_dir(base_dir)
53
53
  const managed_names = new Set(managed_short_names)
54
- const external_skills = (disk_skills || []).filter(s => !managed_names.has(s.name))
54
+ // Unclaimed skills (on disk, not in lock) split into two coherent buckets:
55
+ // - drafts → scaffolded by `init`, HappySkills-shaped skill.json present.
56
+ // Publish/release pick these up directly — no `convert` needed.
57
+ // - external → genuinely foreign (no skill.json, or foreign-shaped).
58
+ // `convert` is the path to bring these into the managed set.
59
+ const unclaimed = (disk_skills || []).filter(s => !managed_names.has(s.name))
60
+ const draft_skills = unclaimed.filter(s => s.is_draft)
61
+ const external_skills = unclaimed.filter(s => !s.is_draft)
55
62
 
56
63
  // Scan agent-specific dirs for skills placed directly in agent folders (not symlinked from .agents/skills/)
57
- const all_known_names = new Set([...managed_names, ...external_skills.map(s => s.name)])
64
+ const all_known_names = new Set([...managed_names, ...draft_skills.map(s => s.name), ...external_skills.map(s => s.name)])
58
65
  const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
59
66
  const orphan_skills = agent_orphans || []
60
67
 
61
- if (managed_entries.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
68
+ if (managed_entries.length === 0 && draft_skills.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
62
69
  if (args.flags.json) {
63
- print_json({ data: { skills: {}, external: [], agent_orphans: [] } })
70
+ print_json({ data: { skills: {}, drafts: [], external: [], agent_orphans: [] } })
64
71
  return
65
72
  }
66
73
  print_info('No skills installed.')
@@ -123,13 +130,19 @@ const run = (args) => catch_errors('List failed', async () => {
123
130
  }
124
131
  skills_map[name] = entry
125
132
  }
133
+ const drafts = draft_skills.map(s => ({
134
+ name: s.name,
135
+ description: s.description || '',
136
+ version: s.version || null,
137
+ type: s.type || SKILL_TYPES.SKILL
138
+ }))
126
139
  const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
127
140
  const agent_orphan_list = orphan_skills.map(s => ({
128
141
  name: s.name,
129
142
  description: s.description || '',
130
143
  agents: s.agents
131
144
  }))
132
- print_json({ data: { skills: skills_map, external, agent_orphans: agent_orphan_list } })
145
+ print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
133
146
  return
134
147
  }
135
148
 
@@ -153,6 +166,11 @@ const run = (args) => catch_errors('List failed', async () => {
153
166
  rows.push([display_name, data.version, source, status_label, enabled_label])
154
167
  }
155
168
 
169
+ for (const s of draft_skills) {
170
+ const type_label = s.type === SKILL_TYPES.KIT ? `${s.name} [kit]` : s.name
171
+ rows.push([type_label, s.version || '-', 'draft', 'unpublished', '-'])
172
+ }
173
+
156
174
  for (const s of external_skills) {
157
175
  rows.push([s.name, '-', 'external', 'installed', '-'])
158
176
  }
@@ -175,6 +193,10 @@ const run = (args) => catch_errors('List failed', async () => {
175
193
  console.log()
176
194
  print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
177
195
  }
196
+ if (draft_skills.length > 0) {
197
+ console.log()
198
+ print_info(`${draft_skills.length} draft(s) ready to publish — run ${code('happyskills release <name>')} to ship.`)
199
+ }
178
200
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
179
201
 
180
202
  module.exports = { run }
@@ -379,7 +379,7 @@ describe('CLI — --json: success responses use { data } envelope', () => {
379
379
  // ─── --json flag: existing commands use { data } envelope ─────────────────────
380
380
 
381
381
  describe('CLI — --json: existing json commands now use { data } envelope', () => {
382
- it('list --json returns { data: { skills, external } }', () => {
382
+ it('list --json returns { data: { skills, drafts, external } }', () => {
383
383
  // Run in a temp dir with no lock file — produces empty result
384
384
  const tmp = make_tmp()
385
385
  try {
@@ -388,14 +388,51 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
388
388
  const out = parse_json_output(stdout, 'list --json empty')
389
389
  assert.ok('data' in out, 'should have top-level "data" key')
390
390
  assert.ok('skills' in out.data, 'data.skills should exist')
391
+ assert.ok('drafts' in out.data, 'data.drafts should exist')
391
392
  assert.ok('external' in out.data, 'data.external should exist')
392
393
  assert.ok(typeof out.data.skills === 'object' && !Array.isArray(out.data.skills))
394
+ assert.ok(Array.isArray(out.data.drafts))
393
395
  assert.ok(Array.isArray(out.data.external))
394
396
  } finally {
395
397
  fs.rmSync(tmp, { recursive: true, force: true })
396
398
  }
397
399
  })
398
400
 
401
+ it('list --json classifies init-scaffolded skills as drafts, foreign-shaped ones as external', () => {
402
+ // Two on-disk skills, no lock file:
403
+ // - "my-draft" has a HappySkills-shaped skill.json (init-style)
404
+ // - "my-foreign" has only SKILL.md (foreign / hand-rolled)
405
+ const tmp = make_tmp()
406
+ try {
407
+ const draft_dir = path.join(tmp, '.agents', 'skills', 'my-draft')
408
+ fs.mkdirSync(draft_dir, { recursive: true })
409
+ fs.writeFileSync(path.join(draft_dir, 'SKILL.md'), '---\nname: my-draft\ndescription: a draft skill\n---\n\n# my-draft\n')
410
+ fs.writeFileSync(path.join(draft_dir, 'skill.json'), JSON.stringify({
411
+ name: 'my-draft',
412
+ version: '0.1.0',
413
+ type: 'skill',
414
+ description: '',
415
+ keywords: [],
416
+ dependencies: {}
417
+ }))
418
+
419
+ const foreign_dir = path.join(tmp, '.agents', 'skills', 'my-foreign')
420
+ fs.mkdirSync(foreign_dir, { recursive: true })
421
+ fs.writeFileSync(path.join(foreign_dir, 'SKILL.md'), '---\nname: my-foreign\ndescription: a foreign skill\n---\n\n# my-foreign\n')
422
+
423
+ const { stdout, code } = run(['list', '--json'], {}, { cwd: tmp })
424
+ assert.strictEqual(code, 0)
425
+ const out = parse_json_output(stdout, 'list --json mixed')
426
+ assert.strictEqual(out.data.drafts.length, 1, 'should have exactly one draft')
427
+ assert.strictEqual(out.data.drafts[0].name, 'my-draft')
428
+ assert.strictEqual(out.data.drafts[0].version, '0.1.0')
429
+ assert.strictEqual(out.data.external.length, 1, 'should have exactly one external')
430
+ assert.strictEqual(out.data.external[0].name, 'my-foreign')
431
+ } finally {
432
+ fs.rmSync(tmp, { recursive: true, force: true })
433
+ }
434
+ })
435
+
399
436
  it('search --json usage error returns { error } not raw text', () => {
400
437
  const { stdout, code } = run(['search', '--json'])
401
438
  assert.strictEqual(code, 2)
@@ -1,9 +1,25 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { error: { catch_errors } } = require('puffy-core')
4
+ const { valid: valid_semver } = require('./semver')
5
+ const { SKILL_JSON, SKILL_TYPES } = require('../constants')
4
6
 
5
7
  const SKIP_ENTRIES = new Set(['.tmp', '.DS_Store', '.install.lock'])
6
8
 
9
+ // A disk-resident, unclaimed skill is a "draft" when it has a HappySkills-shaped
10
+ // skill.json next to its SKILL.md — i.e. the file `init` already produced.
11
+ // "HappySkills-shaped" means: a non-empty `name`, a valid-semver `version`, and
12
+ // a `type` of "skill" or "kit". This is the minimum surface `release`/`publish`
13
+ // can pick up on a no-lock-entry first publish without an intermediate `convert`.
14
+ const is_happyskills_shaped_manifest = (manifest) => {
15
+ if (!manifest || typeof manifest !== 'object') return false
16
+ if (typeof manifest.name !== 'string' || !manifest.name.trim()) return false
17
+ if (!valid_semver(manifest.version)) return false
18
+ const t = manifest.type
19
+ if (t && t !== SKILL_TYPES.SKILL && t !== SKILL_TYPES.KIT) return false
20
+ return true
21
+ }
22
+
7
23
  const parse_frontmatter = (content) => {
8
24
  const match = content.match(/^---\n([\s\S]*?)\n---/)
9
25
  if (!match) return null
@@ -44,9 +60,25 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
44
60
  const frontmatter = parse_frontmatter(content)
45
61
  if (!frontmatter || !frontmatter.name) continue
46
62
 
63
+ // Detect whether this skill has a HappySkills-shaped manifest. If yes,
64
+ // callers can classify it as a "draft" (scaffolded by `init`, not yet
65
+ // claimed in the lock) rather than "external" (genuinely foreign).
66
+ let manifest = null
67
+ try {
68
+ const raw = await fs.promises.readFile(path.join(dir, SKILL_JSON), 'utf-8')
69
+ manifest = JSON.parse(raw)
70
+ } catch {
71
+ // No skill.json — genuinely external. manifest stays null.
72
+ }
73
+
74
+ const is_draft = is_happyskills_shaped_manifest(manifest)
75
+
47
76
  skills.push({
48
77
  name: frontmatter.name,
49
- description: frontmatter.description || ''
78
+ description: frontmatter.description || '',
79
+ is_draft,
80
+ version: is_draft ? manifest.version : null,
81
+ type: is_draft ? (manifest.type || SKILL_TYPES.SKILL) : null
50
82
  })
51
83
  }
52
84
 
@@ -129,4 +161,4 @@ const scan_agent_orphan_skills = (agents, is_global, project_root, known_names)
129
161
  return [...by_name.values()]
130
162
  })
131
163
 
132
- module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter }
164
+ module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter, is_happyskills_shaped_manifest }