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 +11 -0
- package/package.json +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/list.js +27 -5
- package/src/integration/cli.test.js +38 -1
- package/src/utils/skill_scanner.js +34 -2
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
package/src/commands/init.js
CHANGED
|
@@ -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(
|
|
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 }
|
package/src/commands/list.js
CHANGED
|
@@ -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
|
-
|
|
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 }
|