happyskills 0.26.0 → 0.27.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,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.27.0] - 2026-04-02
11
+
12
+ ### Added
13
+ - Add agent-orphan skill detection to `list` command — scans all agent-specific skill directories (`.claude/skills/`, `.cursor/skills/`, etc.) for skills placed directly in agent folders without going through HappySkills, and reports them as `agent_orphans` in JSON output or `agent-orphan (<agent>)` source in table output
14
+
10
15
  ## [0.26.0] - 2026-04-01
11
16
 
12
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.26.0",
3
+ "version": "0.27.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)",
@@ -2,7 +2,8 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
3
  const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
4
4
  const { file_exists, read_json } = require('../utils/fs')
5
- const { scan_skills_dir } = require('../utils/skill_scanner')
5
+ const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
6
+ const { AGENTS } = require('../agents/registry')
6
7
  const { print_help, print_table, print_json, print_info } = require('../ui/output')
7
8
  const { exit_with_error } = require('../utils/errors')
8
9
  const { EXIT_CODES, SKILL_JSON, SKILL_TYPES } = require('../constants')
@@ -40,9 +41,14 @@ const run = (args) => catch_errors('List failed', async () => {
40
41
  const managed_names = new Set(managed_entries.map(([k]) => k.split('/')[1]))
41
42
  const external_skills = (disk_skills || []).filter(s => !managed_names.has(s.name))
42
43
 
43
- if (managed_entries.length === 0 && external_skills.length === 0) {
44
+ // Scan agent-specific dirs for skills placed directly in agent folders (not symlinked from .agents/skills/)
45
+ const all_known_names = new Set([...managed_names, ...external_skills.map(s => s.name)])
46
+ const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
47
+ const orphan_skills = agent_orphans || []
48
+
49
+ if (managed_entries.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
44
50
  if (args.flags.json) {
45
- print_json({ data: { skills: {}, external: [] } })
51
+ print_json({ data: { skills: {}, external: [], agent_orphans: [] } })
46
52
  return
47
53
  }
48
54
  print_info('No skills installed.')
@@ -69,7 +75,12 @@ const run = (args) => catch_errors('List failed', async () => {
69
75
  skills_map[name] = { version: data.version, type, source, status }
70
76
  }
71
77
  const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
72
- print_json({ data: { skills: skills_map, external } })
78
+ const agent_orphan_list = orphan_skills.map(s => ({
79
+ name: s.name,
80
+ description: s.description || '',
81
+ agents: s.agents
82
+ }))
83
+ print_json({ data: { skills: skills_map, external, agent_orphans: agent_orphan_list } })
73
84
  return
74
85
  }
75
86
 
@@ -88,6 +99,11 @@ const run = (args) => catch_errors('List failed', async () => {
88
99
  rows.push([s.name, '-', 'external', 'installed'])
89
100
  }
90
101
 
102
+ for (const s of orphan_skills) {
103
+ const agent_label = s.agents.map(a => a.name).join(', ')
104
+ rows.push([s.name, '-', `agent-orphan (${agent_label})`, 'installed'])
105
+ }
106
+
91
107
  print_table(['Skill', 'Version', 'Source', 'Status'], rows)
92
108
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
93
109
 
@@ -53,4 +53,80 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
53
53
  return skills
54
54
  })
55
55
 
56
- module.exports = { scan_skills_dir, parse_frontmatter }
56
+ /**
57
+ * Scans agent-specific skill directories for skills that are NOT symlinks
58
+ * back to the canonical .agents/skills/ directory. These are "agent-orphan"
59
+ * skills — placed directly in an agent folder without going through HappySkills.
60
+ *
61
+ * @param {Array} agents - Agent definitions from the registry (AGENTS array)
62
+ * @param {boolean} is_global - Whether to scan global or project-level dirs
63
+ * @param {string} project_root - Project root path
64
+ * @param {Set<string>} known_names - Names already tracked (managed + external from canonical dir)
65
+ * @returns {[Error|null, Array<{name:string, description:string, agent_id:string, agent_name:string}>]}
66
+ */
67
+ const scan_agent_orphan_skills = (agents, is_global, project_root, known_names) => catch_errors('Failed to scan agent skill directories', async () => {
68
+ const orphans = []
69
+
70
+ for (const agent of agents) {
71
+ const agent_dir = is_global
72
+ ? path.join(require('os').homedir(), agent.global_skills_dir)
73
+ : path.join(project_root || process.cwd(), agent.skills_dir)
74
+
75
+ let entries
76
+ try {
77
+ entries = await fs.promises.readdir(agent_dir, { withFileTypes: true })
78
+ } catch {
79
+ continue
80
+ }
81
+
82
+ for (const entry of entries) {
83
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
84
+ if (entry.name.startsWith('.') || SKIP_ENTRIES.has(entry.name)) continue
85
+
86
+ // Skip symlinks — those are managed by HappySkills
87
+ if (entry.isSymbolicLink()) continue
88
+
89
+ // Skip skills already known from the canonical dir or lock file
90
+ if (known_names.has(entry.name)) continue
91
+
92
+ const dir = path.join(agent_dir, entry.name)
93
+ const skill_md_path = path.join(dir, 'SKILL.md')
94
+
95
+ let content
96
+ try {
97
+ content = await fs.promises.readFile(skill_md_path, 'utf-8')
98
+ } catch {
99
+ continue
100
+ }
101
+
102
+ const frontmatter = parse_frontmatter(content)
103
+ if (!frontmatter || !frontmatter.name) continue
104
+
105
+ orphans.push({
106
+ name: frontmatter.name,
107
+ description: frontmatter.description || '',
108
+ agent_id: agent.id,
109
+ agent_name: agent.display_name
110
+ })
111
+ }
112
+ }
113
+
114
+ // Deduplicate — the same skill dir name might appear in multiple agent dirs.
115
+ // Keep the first occurrence and collect all agent IDs.
116
+ const by_name = new Map()
117
+ for (const s of orphans) {
118
+ if (by_name.has(s.name)) {
119
+ by_name.get(s.name).agents.push({ id: s.agent_id, name: s.agent_name })
120
+ } else {
121
+ by_name.set(s.name, {
122
+ name: s.name,
123
+ description: s.description,
124
+ agents: [{ id: s.agent_id, name: s.agent_name }]
125
+ })
126
+ }
127
+ }
128
+
129
+ return [...by_name.values()]
130
+ })
131
+
132
+ module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter }