happyskills 0.9.1 → 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,15 @@ 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
+
10
19
  ## [0.9.1] - 2026-03-10
11
20
 
12
21
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.9.1",
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()
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
+ })