happyskills 0.9.0 → 0.10.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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-03-11
11
+
12
+ ### Added
13
+ - Add `delete` command to permanently remove a skill from the registry — requires authentication, interactive confirmation (type full `owner/name`), and `-y`/`--yes` in JSON mode; alias `del`
14
+ - Add `-g`/`--global` flag to `init` command for scaffolding skills in the global `~/.claude/skills/` directory
15
+
16
+ ### Changed
17
+ - Change `init` to create skills in `.claude/skills/<name>/` instead of the current directory — name argument is now required; `.claude/skills/` is auto-created if missing
18
+
19
+ ## [0.9.1] - 2026-03-10
20
+
21
+ ### Changed
22
+ - Change `publish` to run full skill validation (SKILL.md + skill.json + cross-file rules) before publishing, blocking on errors with structured `VALIDATION_FAILED` output in JSON mode — replaces the previous ad-hoc manifest validation and frontmatter warnings
23
+
10
24
  ## [0.9.0] - 2026-03-10
11
25
 
12
26
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.9.0",
3
+ "version": "0.10.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/repos.js CHANGED
@@ -52,4 +52,10 @@ const check_updates = (skills) => catch_errors('Check updates failed', async ()
52
52
  return data
53
53
  })
54
54
 
55
- module.exports = { search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates }
55
+ const del_repo = (owner, name) => catch_errors(`Failed to delete ${owner}/${name}`, async () => {
56
+ const [errors, data] = await client.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`)
57
+ if (errors) throw errors[errors.length - 1]
58
+ return data
59
+ })
60
+
61
+ module.exports = { search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo }
@@ -0,0 +1,78 @@
1
+ const readline = require('readline')
2
+ const { error: { catch_errors } } = require('puffy-core')
3
+ const { del_repo } = require('../api/repos')
4
+ const { require_token } = require('../auth/token_store')
5
+ const { print_help, print_json, print_success, print_warn } = require('../ui/output')
6
+ const { exit_with_error, UsageError, CliError } = require('../utils/errors')
7
+ const { EXIT_CODES } = require('../constants')
8
+
9
+ const HELP_TEXT = `Usage: happyskills delete <owner/name> [options]
10
+
11
+ Permanently delete a skill from the registry.
12
+
13
+ Arguments:
14
+ owner/name Skill to delete (e.g., acme/deploy-aws)
15
+
16
+ Options:
17
+ -y, --yes Skip confirmation prompt (required with --json)
18
+ --json Output as JSON
19
+
20
+ Aliases: del
21
+
22
+ Examples:
23
+ happyskills delete acme/deploy-aws
24
+ happyskills delete acme/deploy-aws --json -y`
25
+
26
+ const _ask_confirmation = (skill) => new Promise((resolve) => {
27
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
28
+ print_warn(`You are about to permanently delete "${skill}" from the registry.`)
29
+ print_warn('This action cannot be undone.')
30
+ rl.question(`\nType "${skill}" to confirm: `, (answer) => {
31
+ rl.close()
32
+ resolve(answer.trim() === skill)
33
+ })
34
+ })
35
+
36
+ const run = (args) => catch_errors('Delete failed', async () => {
37
+ if (args.flags._show_help) {
38
+ print_help(HELP_TEXT)
39
+ return process.exit(EXIT_CODES.SUCCESS)
40
+ }
41
+
42
+ const skill = args._[0]
43
+ if (!skill) {
44
+ throw new UsageError('Please specify a skill to delete (e.g., happyskills delete acme/deploy-aws).')
45
+ }
46
+
47
+ if (!skill.includes('/')) {
48
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
49
+ }
50
+
51
+ await require_token()
52
+
53
+ const [owner, name] = skill.split('/')
54
+
55
+ if (args.flags.json) {
56
+ if (!args.flags.yes) {
57
+ throw new CliError('Confirmation required. Use -y or --yes to confirm deletion in JSON mode.', EXIT_CODES.ERROR)
58
+ }
59
+ } else {
60
+ const confirmed = await _ask_confirmation(skill)
61
+ if (!confirmed) {
62
+ print_warn('Delete cancelled.')
63
+ return process.exit(EXIT_CODES.SUCCESS)
64
+ }
65
+ }
66
+
67
+ const [errors, result] = await del_repo(owner, name)
68
+ if (errors) throw errors[errors.length - 1]
69
+
70
+ if (args.flags.json) {
71
+ print_json({ data: { deleted: true, skill, ...(result || {}) } })
72
+ return
73
+ }
74
+
75
+ print_success(`Deleted "${skill}" from the registry.`)
76
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
77
+
78
+ module.exports = { run }
@@ -1,28 +1,30 @@
1
1
  const path = require('path')
2
2
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
3
3
  const { write_manifest } = require('../manifest/writer')
4
- const { write_file, file_exists } = require('../utils/fs')
4
+ const { write_file, file_exists, ensure_dir } = require('../utils/fs')
5
5
  const { print_success, print_error, print_help, print_hint, print_json, code } = require('../ui/output')
6
- const { exit_with_error, CliError } = require('../utils/errors')
6
+ const { exit_with_error, CliError, UsageError } = require('../utils/errors')
7
7
  const { SKILL_JSON, SKILL_MD, EXIT_CODES } = require('../constants')
8
+ const { find_project_root, skills_dir } = require('../config/paths')
8
9
 
9
- const HELP_TEXT = `Usage: happyskills init [name]
10
+ const HELP_TEXT = `Usage: happyskills init <name> [options]
10
11
 
11
- Scaffold a new skill in the current directory.
12
+ Scaffold a new skill in .claude/skills/<name>/.
12
13
 
13
14
  Creates:
14
- skill.json Skill manifest (name, version, deps)
15
- SKILL.md Skill instructions for AI agents
15
+ .claude/skills/<name>/skill.json Skill manifest (name, version, deps)
16
+ .claude/skills/<name>/SKILL.md Skill instructions for AI agents
16
17
 
17
18
  Arguments:
18
- name Skill name (defaults to directory name)
19
+ name Skill name (required, e.g., my-deploy-skill)
19
20
 
20
21
  Options:
22
+ -g, --global Create in global skills (~/.claude/skills/)
21
23
  --json Output as JSON
22
24
 
23
25
  Examples:
24
- happyskills init
25
- happyskills init my-deploy-skill`
26
+ happyskills init my-deploy-skill
27
+ happyskills init my-deploy-skill -g`
26
28
 
27
29
  const create_skill_md = (name) => `---
28
30
  name: ${name}
@@ -50,14 +52,24 @@ const run = (args) => catch_errors('Init failed', async () => {
50
52
  return process.exit(EXIT_CODES.SUCCESS)
51
53
  }
52
54
 
53
- const dir = process.cwd()
54
- const name = args._[0] || path.basename(dir)
55
+ const name = args._[0]
56
+ if (!name) {
57
+ throw new UsageError('Please specify a skill name (e.g., happyskills init my-deploy-skill).')
58
+ }
59
+
60
+ const is_global = args.flags.global || false
61
+ const project_root = find_project_root()
62
+ const base_skills_dir = skills_dir(is_global, project_root)
63
+ const dir = path.join(base_skills_dir, name)
55
64
 
56
65
  const [, json_exists] = await file_exists(path.join(dir, SKILL_JSON))
57
66
  if (json_exists) {
58
- throw new CliError(`${SKILL_JSON} already exists in this directory.`, EXIT_CODES.ERROR)
67
+ throw new CliError(`${SKILL_JSON} already exists at ${dir}`, EXIT_CODES.ERROR)
59
68
  }
60
69
 
70
+ const [dir_err] = await ensure_dir(dir)
71
+ if (dir_err) throw e('Failed to create skill directory', dir_err)
72
+
61
73
  const manifest = create_manifest(name)
62
74
  const [write_err] = await write_manifest(dir, manifest)
63
75
  if (write_err) throw e('Failed to write manifest', write_err)
@@ -76,7 +88,7 @@ const run = (args) => catch_errors('Init failed', async () => {
76
88
  return
77
89
  }
78
90
 
79
- print_success(`Initialized skill "${name}"`)
91
+ print_success(`Initialized skill "${name}" at ${dir}`)
80
92
  console.log(` ${SKILL_JSON} — manifest`)
81
93
  console.log(` ${SKILL_MD} — instructions`)
82
94
  console.log()
@@ -1,9 +1,7 @@
1
- const fs = require('fs')
2
1
  const path = require('path')
3
2
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
3
  const { read_manifest } = require('../manifest/reader')
5
4
  const { write_manifest } = require('../manifest/writer')
6
- const { validate_manifest } = require('../manifest/validator')
7
5
  const { require_token } = require('../auth/token_store')
8
6
  const repos_api = require('../api/repos')
9
7
  const workspaces_api = require('../api/workspaces')
@@ -15,11 +13,13 @@ const { find_project_root } = require('../config/paths')
15
13
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
16
14
  const { write_lock, update_lock_skills } = require('../lock/writer')
17
15
  const { hash_directory } = require('../lock/integrity')
18
- const { parse_frontmatter } = require('../utils/skill_scanner')
16
+ const { validate_skill_md } = require('../validation/skill_md_rules')
17
+ const { validate_skill_json } = require('../validation/skill_json_rules')
18
+ const { validate_cross } = require('../validation/cross_rules')
19
19
  const { create_spinner } = require('../ui/spinner')
20
20
  const { print_help, print_success, print_error, print_warn, print_hint, print_json, code } = require('../ui/output')
21
21
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
22
- const { EXIT_CODES, SKILL_MD } = require('../constants')
22
+ const { EXIT_CODES } = require('../constants')
23
23
 
24
24
  const HELP_TEXT = `Usage: happyskills publish <skill-name> [options]
25
25
 
@@ -85,34 +85,39 @@ const run = (args) => catch_errors('Publish failed', async () => {
85
85
  if (write_err) throw e('Failed to update manifest version', write_err)
86
86
  }
87
87
 
88
- const validation = validate_manifest(manifest)
89
- if (!validation.valid) {
90
- throw new CliError(`Invalid skill.json: ${validation.errors.join(', ')}`, EXIT_CODES.ERROR)
91
- }
92
-
93
- // Validate SKILL.md frontmatter — warn about missing name/description
94
- const frontmatter_warnings = []
95
- try {
96
- const skill_md_content = await fs.promises.readFile(path.join(dir, SKILL_MD), 'utf-8')
97
- const frontmatter = parse_frontmatter(skill_md_content)
98
- if (!frontmatter) {
99
- frontmatter_warnings.push('SKILL.md has no YAML frontmatter (---). Without frontmatter, Claude cannot auto-invoke this skill.')
100
- } else {
101
- if (!frontmatter.name) {
102
- frontmatter_warnings.push('SKILL.md frontmatter is missing "name". Add name: <skill-name> to the frontmatter.')
103
- }
104
- if (!frontmatter.description) {
105
- frontmatter_warnings.push('SKILL.md frontmatter is missing "description". The description is the #1 factor for Claude auto-invocation quality. Without it, the skill will not be auto-invoked.')
106
- }
88
+ // Run full validation block on errors, show warnings
89
+ const dir_name = path.basename(dir)
90
+ const [md_err, md_data] = await validate_skill_md(dir, dir_name)
91
+ if (md_err) throw md_err
92
+ const [json_err, json_data] = await validate_skill_json(dir)
93
+ if (json_err) throw json_err
94
+ const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content)
95
+ if (cross_err) throw cross_err
96
+
97
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results]
98
+ const validation_errors = all_results.filter(r => r.severity === 'error')
99
+ const validation_warnings = all_results.filter(r => r.severity === 'warning')
100
+
101
+ if (validation_errors.length > 0) {
102
+ const { is_json_mode } = require('../state')
103
+ if (is_json_mode()) {
104
+ const structured_errors = validation_errors.map(({ severity, ...rest }) => rest)
105
+ print_json({
106
+ error: {
107
+ code: 'VALIDATION_FAILED',
108
+ message: `Skill failed validation with ${validation_errors.length} error(s). Fix these issues and try again.`,
109
+ exit_code: EXIT_CODES.ERROR,
110
+ validation_errors: structured_errors
111
+ }
112
+ })
113
+ return process.exit(EXIT_CODES.ERROR)
107
114
  }
108
- } catch {
109
- frontmatter_warnings.push(`No ${SKILL_MD} found. The skill may not work correctly.`)
115
+ const error_msgs = validation_errors.map(r => `${r.file || 'General'}${r.field ? ` (${r.field})` : ''}: ${r.message}`)
116
+ throw new CliError(`Skill failed validation with ${validation_errors.length} error(s):\n ${error_msgs.join('\n ')}\n\nRun \`happyskills validate ${skill_name}\` for full details.`, EXIT_CODES.ERROR)
110
117
  }
111
118
 
112
- if (frontmatter_warnings.length > 0) {
113
- for (const warning of frontmatter_warnings) {
114
- print_warn(warning)
115
- }
119
+ for (const w of validation_warnings) {
120
+ print_warn(`${w.file || 'General'}${w.field ? ` (${w.field})` : ''}: ${w.message}`)
116
121
  }
117
122
 
118
123
  const spinner = create_spinner('Preparing to publish...')
@@ -199,7 +204,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
199
204
  commit: push_data?.commit || null,
200
205
  bumped_from
201
206
  }
202
- if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
207
+ if (validation_warnings.length > 0) json_data.warnings = validation_warnings.map(w => w.message)
203
208
  print_json({ data: json_data })
204
209
  return
205
210
  }
package/src/constants.js CHANGED
@@ -29,13 +29,15 @@ const COMMAND_ALIASES = {
29
29
  r: 'refresh',
30
30
  up: 'update',
31
31
  pub: 'publish',
32
- v: 'validate'
32
+ v: 'validate',
33
+ del: 'delete'
33
34
  }
34
35
 
35
36
  const COMMANDS = [
36
37
  'init',
37
38
  'install',
38
39
  'uninstall',
40
+ 'delete',
39
41
  'list',
40
42
  'search',
41
43
  'check',
package/src/index.js CHANGED
@@ -87,6 +87,7 @@ Commands:
87
87
  init [name] Scaffold SKILL.md + skill.json
88
88
  install [owner/skill] Install skill + dependencies (alias: i, add)
89
89
  uninstall <owner/skill> Remove skill + prune orphans (alias: rm, remove)
90
+ delete <owner/skill> Delete skill from registry (alias: del)
90
91
  list List installed skills (alias: ls)
91
92
  search <query> Search the registry (alias: s)
92
93
  check [owner/skill] Check for available updates
@@ -315,9 +315,11 @@ describe('CLI — --json: success responses use { data } envelope', () => {
315
315
  it('init --json error when skill.json already exists returns { error }', () => {
316
316
  const tmp = make_tmp()
317
317
  try {
318
- // Create skill.json first
319
- fs.writeFileSync(path.join(tmp, 'skill.json'), '{}')
320
- const { stdout, code } = run(['init', '--json'], {}, { cwd: tmp })
318
+ // Create .claude/skills/test-skill/skill.json first
319
+ const skill_dir = path.join(tmp, '.claude', 'skills', 'test-skill')
320
+ fs.mkdirSync(skill_dir, { recursive: true })
321
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), '{}')
322
+ const { stdout, code } = run(['init', 'test-skill', '--json'], {}, { cwd: tmp })
321
323
  assert.strictEqual(code, 1)
322
324
  const out = parse_json_output(stdout, 'init --json duplicate')
323
325
  assert.ok('error' in out)
@@ -606,3 +608,83 @@ describe('CLI — validate command', () => {
606
608
  }
607
609
  })
608
610
  })
611
+
612
+ // ─── delete command ───────────────────────────────────────────────────────────
613
+
614
+ describe('CLI — delete command', () => {
615
+ it('delete --help outputs correct help text and exits 0', () => {
616
+ const { stdout, code } = run(['delete', '--help'])
617
+ assert.strictEqual(code, 0)
618
+ assert.ok(stdout.includes('Arguments:'))
619
+ assert.ok(stdout.includes('Options:'))
620
+ assert.ok(stdout.includes('Examples:'))
621
+ assert.ok(stdout.includes('Aliases:'))
622
+ })
623
+
624
+ it('delete without arguments exits with code 2 (usage error)', () => {
625
+ const { code, stderr } = run(['delete'])
626
+ assert.strictEqual(code, 2)
627
+ assert.ok(stderr.includes('specify a skill'))
628
+ })
629
+
630
+ it('delete with no slash exits with code 2 and shows owner/name format message', () => {
631
+ const { code, stderr } = run(['delete', 'deploy-aws'])
632
+ assert.strictEqual(code, 2)
633
+ assert.ok(stderr.includes('owner/name format'))
634
+ })
635
+
636
+ it('del alias resolves to delete (shows correct --help)', () => {
637
+ const { stdout, code } = run(['del', '--help'])
638
+ assert.strictEqual(code, 0)
639
+ assert.ok(stdout.toLowerCase().includes('delete'))
640
+ })
641
+
642
+ it('delete acme/deploy-aws --json without -y exits with code 1 (confirmation required)', () => {
643
+ const tmp_xdg = make_tmp()
644
+ try {
645
+ const creds_dir = path.join(tmp_xdg, 'happyskills')
646
+ fs.mkdirSync(creds_dir, { recursive: true })
647
+ const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
648
+ const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
649
+ fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
650
+ id_token: fake_jwt,
651
+ access_token: 'fake-access',
652
+ refresh_token: 'fake-refresh',
653
+ expires_in: 3600,
654
+ stored_at: new Date().toISOString()
655
+ }, null, '\t'))
656
+ const { stdout, code } = run(['delete', 'acme/deploy-aws', '--json'], { XDG_CONFIG_HOME: tmp_xdg })
657
+ assert.strictEqual(code, 1)
658
+ const out = parse_json_output(stdout, 'delete --json no -y')
659
+ assert.ok('error' in out)
660
+ assert.ok(out.error.message.toLowerCase().includes('confirmation'))
661
+ } finally {
662
+ fs.rmSync(tmp_xdg, { recursive: true, force: true })
663
+ }
664
+ })
665
+
666
+ it('delete acme/deploy-aws --json -y exits with code 4 (network error)', () => {
667
+ const tmp_xdg = make_tmp()
668
+ try {
669
+ const creds_dir = path.join(tmp_xdg, 'happyskills')
670
+ fs.mkdirSync(creds_dir, { recursive: true })
671
+ const payload = { email: 'test@example.com', 'cognito:username': 'testuser', sub: 'test-sub-123' }
672
+ const fake_jwt = `eyJhbGciOiJSUzI1NiJ9.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.fakesig`
673
+ fs.writeFileSync(path.join(creds_dir, 'credentials.json'), JSON.stringify({
674
+ id_token: fake_jwt,
675
+ access_token: 'fake-access',
676
+ refresh_token: 'fake-refresh',
677
+ expires_in: 3600,
678
+ stored_at: new Date().toISOString()
679
+ }, null, '\t'))
680
+ const { stdout, code } = run(['delete', 'acme/deploy-aws', '--json', '-y'], { XDG_CONFIG_HOME: tmp_xdg })
681
+ assert.strictEqual(code, 4)
682
+ const out = parse_json_output(stdout, 'delete --json -y network failure')
683
+ assert.ok('error' in out)
684
+ assert.strictEqual(out.error.code, 'NETWORK_ERROR')
685
+ assert.strictEqual(out.error.exit_code, 4)
686
+ } finally {
687
+ fs.rmSync(tmp_xdg, { recursive: true, force: true })
688
+ }
689
+ })
690
+ })