happyskills 0.4.3 → 0.5.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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-03-05
11
+
12
+ ### Added
13
+ - Add `--mine` flag to `search` command to browse skills across all user's workspaces
14
+ - Add `--personal` flag to `search` command to browse skills in the user's personal workspace only
15
+ - Add `-w` / `--workspace <slug>` flag to `search` command to search within specific workspace(s)
16
+ - Add `--tags <tags>` flag to `search` command to filter results by tags
17
+ - Add `scope` and `visibility` fields to `search` JSON output
18
+
19
+ ### Changed
20
+ - Change `search` query argument from required to optional when `--mine`, `--personal`, or `--workspace` is provided (browse mode)
21
+
22
+ ## [0.4.4] - 2026-03-05
23
+
24
+ ### Fixed
25
+ - Fix all commands (`install`, `list`, `setup`, `update`, `uninstall`, `check`, `bump`, `convert`) inheriting skills from a parent directory; `find_project_root()` now always uses the current working directory and never walks up the directory tree
26
+
10
27
  ## [0.4.3] - 2026-03-05
11
28
 
12
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.4.3",
3
+ "version": "0.5.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
@@ -2,10 +2,15 @@ const { error: { catch_errors } } = require('puffy-core')
2
2
  const client = require('./client')
3
3
 
4
4
  const search = (query, options = {}) => catch_errors('Search failed', async () => {
5
- const params = new URLSearchParams({ q: query })
5
+ const params = new URLSearchParams()
6
+ if (query) params.set('q', query)
6
7
  if (options.limit) params.set('limit', options.limit)
7
8
  if (options.offset) params.set('offset', options.offset)
8
- const [errors, data] = await client.get(`/repos/search?${params}`, { auth: false })
9
+ if (options.scope) params.set('scope', options.scope)
10
+ if (options.workspace) params.set('workspace', options.workspace)
11
+ if (options.tags) params.set('tags', options.tags)
12
+ const needs_auth = (options.scope && options.scope !== 'public') || options.workspace
13
+ const [errors, data] = await client.get(`/repos/search?${params}`, { auth: needs_auth || false })
9
14
  if (errors) throw errors[errors.length - 1]
10
15
  return data
11
16
  })
@@ -1,24 +1,31 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const repos_api = require('../api/repos')
3
3
  const { print_help, print_table, print_json, print_info } = require('../ui/output')
4
- const { exit_with_error, UsageError } = require('../utils/errors')
4
+ const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
5
5
  const { EXIT_CODES } = require('../constants')
6
+ const { load_token } = require('../auth/token_store')
6
7
 
7
- const HELP_TEXT = `Usage: happyskills search <query> [options]
8
+ const HELP_TEXT = `Usage: happyskills search [query] [options]
8
9
 
9
10
  Search the skill registry.
10
11
 
11
12
  Arguments:
12
- query Search term
13
+ query Search term (optional with --mine, --personal, or --workspace)
13
14
 
14
15
  Options:
15
- --json Output as JSON
16
+ -w, --workspace <slug> Search within specific workspace(s) (comma-separated)
17
+ --mine Search across all your workspaces
18
+ --personal Search only your personal workspace
19
+ --tags <tags> Filter by tags (comma-separated)
20
+ --json Output as JSON
16
21
 
17
22
  Aliases: s
18
23
 
19
24
  Examples:
20
25
  happyskills search deploy
21
- happyskills s aws --json`
26
+ happyskills search --mine
27
+ happyskills search deploy --workspace acme
28
+ happyskills s --personal --json`
22
29
 
23
30
  const run = (args) => catch_errors('Search failed', async () => {
24
31
  if (args.flags._show_help) {
@@ -26,32 +33,52 @@ const run = (args) => catch_errors('Search failed', async () => {
26
33
  return process.exit(EXIT_CODES.SUCCESS)
27
34
  }
28
35
 
29
- const query = args._.join(' ')
30
- if (!query) {
31
- throw new UsageError('Please provide a search query (e.g., happyskills search deploy).')
36
+ const query = args._.join(' ') || null
37
+ const { mine, personal, workspace, tags } = args.flags
38
+ const has_scope_flag = mine || personal || workspace
39
+
40
+ if (!query && !has_scope_flag && !tags) {
41
+ throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
42
+ }
43
+
44
+ const scope = mine ? 'mine' : personal ? 'personal' : undefined
45
+
46
+ if (has_scope_flag) {
47
+ const [, token_data] = await load_token()
48
+ if (!token_data) {
49
+ throw new AuthError('Authentication required. Run `happyskills login` first.')
50
+ }
32
51
  }
33
52
 
34
- const [errors, results] = await repos_api.search(query)
53
+ const options = {}
54
+ if (scope) options.scope = scope
55
+ if (workspace) options.workspace = workspace
56
+ if (tags) options.tags = tags
57
+
58
+ const [errors, results] = await repos_api.search(query, options)
35
59
  if (errors) throw e('Search failed', errors)
36
60
 
37
61
  const items = Array.isArray(results) ? results : (results?.repos || results?.items || [])
62
+ const effective_scope = scope || (has_scope_flag ? undefined : undefined)
38
63
 
39
64
  if (items.length === 0) {
40
65
  if (args.flags.json) {
41
- print_json({ data: { query, results: [], count: 0 } })
66
+ print_json({ data: { query, scope: effective_scope || 'all', results: [], count: 0 } })
42
67
  return
43
68
  }
44
- print_info(`No skills found for "${query}".`)
69
+ const context = query ? ` for "${query}"` : ''
70
+ print_info(`No skills found${context}.`)
45
71
  return
46
72
  }
47
73
 
48
74
  if (args.flags.json) {
49
- const results = items.map(item => ({
75
+ const mapped = items.map(item => ({
50
76
  skill: `${item.owner || item.workspace_slug}/${item.name}`,
51
77
  description: item.description || '',
52
- version: item.latest_version || item.version || '-'
78
+ version: item.latest_version || item.version || '-',
79
+ visibility: item.visibility || 'public'
53
80
  }))
54
- print_json({ data: { query, results, count: results.length } })
81
+ print_json({ data: { query, scope: effective_scope || 'all', results: mapped, count: mapped.length } })
55
82
  return
56
83
  }
57
84
 
@@ -34,30 +34,7 @@ const lock_file_path = (project_root = process.cwd()) => path.join(project_root,
34
34
 
35
35
  const skill_install_dir = (base_skills_dir, name) => path.join(base_skills_dir, name)
36
36
 
37
- const find_project_root = (start_dir = process.cwd()) => {
38
- let dir = path.resolve(start_dir)
39
- while (true) {
40
- // Never treat the home directory as a project root —
41
- // ~/.claude/skills is the global skills dir, not a project,
42
- // and ~/skills-lock.json is an artifact of old buggy behaviour.
43
- if (dir !== home_dir) {
44
- const skills = path.join(dir, '.claude', 'skills')
45
- const lock = path.join(dir, 'skills-lock.json')
46
- try {
47
- fs.statSync(skills)
48
- return dir
49
- } catch {}
50
- try {
51
- fs.statSync(lock)
52
- return dir
53
- } catch {}
54
- }
55
- const parent = path.dirname(dir)
56
- if (parent === dir) break
57
- dir = parent
58
- }
59
- return start_dir
60
- }
37
+ const find_project_root = (start_dir = process.cwd()) => path.resolve(start_dir)
61
38
 
62
39
  module.exports = {
63
40
  home_dir,
@@ -100,77 +100,47 @@ describe('paths', () => {
100
100
  })
101
101
 
102
102
  describe('find_project_root', () => {
103
- it('finds project root by .claude/skills dir', () => {
103
+ it('returns the given directory as-is', () => {
104
104
  const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hs-test-')))
105
105
  try {
106
- fs.mkdirSync(path.join(tmp, '.claude', 'skills'), { recursive: true })
107
106
  assert.strictEqual(paths.find_project_root(tmp), tmp)
108
107
  } finally {
109
108
  fs.rmSync(tmp, { recursive: true })
110
109
  }
111
110
  })
112
111
 
113
- it('finds project root by skills-lock.json', () => {
114
- const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hs-test-')))
115
- try {
116
- fs.writeFileSync(path.join(tmp, 'skills-lock.json'), '{}')
117
- assert.strictEqual(paths.find_project_root(tmp), tmp)
118
- } finally {
119
- fs.rmSync(tmp, { recursive: true })
120
- }
121
- })
122
-
123
- it('walks up to find project root from nested dir', () => {
112
+ it('does not walk up to a parent with .claude/skills', () => {
124
113
  const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hs-test-')))
125
114
  try {
126
115
  fs.mkdirSync(path.join(tmp, '.claude', 'skills'), { recursive: true })
127
- const sub = path.join(tmp, 'src', 'deep')
116
+ const sub = path.join(tmp, 'sub')
128
117
  fs.mkdirSync(sub, { recursive: true })
129
- assert.strictEqual(paths.find_project_root(sub), tmp)
118
+ // should return sub, not tmp
119
+ assert.strictEqual(paths.find_project_root(sub), sub)
130
120
  } finally {
131
121
  fs.rmSync(tmp, { recursive: true })
132
122
  }
133
123
  })
134
124
 
135
- it('falls back to start_dir when no project markers found', () => {
125
+ it('does not walk up to a parent with skills-lock.json', () => {
136
126
  const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hs-test-')))
137
127
  try {
128
+ fs.writeFileSync(path.join(tmp, 'skills-lock.json'), '{}')
138
129
  const sub = path.join(tmp, 'sub')
139
130
  fs.mkdirSync(sub, { recursive: true })
131
+ // should return sub, not tmp
140
132
  assert.strictEqual(paths.find_project_root(sub), sub)
141
133
  } finally {
142
134
  fs.rmSync(tmp, { recursive: true })
143
135
  }
144
136
  })
145
137
 
146
- it('does not treat ~/.claude/skills as a project marker', () => {
147
- const global_skills = path.join(os.homedir(), '.claude', 'skills')
148
- let global_exists = false
149
- try { fs.statSync(global_skills); global_exists = true } catch {}
150
- if (!global_exists) return // skip if global dir not present on this machine
151
-
152
- const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hs-test-')))
153
- try {
154
- // tmp has no project markers — should fall back to tmp, not home dir
155
- assert.strictEqual(paths.find_project_root(tmp), tmp)
156
- } finally {
157
- fs.rmSync(tmp, { recursive: true })
158
- }
138
+ it('returns home dir only when explicitly called with it', () => {
139
+ assert.strictEqual(paths.find_project_root(os.homedir()), os.homedir())
159
140
  })
160
141
 
161
- it('does not treat ~/skills-lock.json as a project marker', () => {
162
- // The home directory itself must never be returned as project root,
163
- // even if a stale skills-lock.json exists there from old buggy behaviour.
164
- // We verify this by running from a temp dir that has no project markers
165
- // of its own — the result must be the temp dir, not the home dir.
166
- const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hs-test-')))
167
- try {
168
- const result = paths.find_project_root(tmp)
169
- assert.notStrictEqual(result, os.homedir())
170
- assert.strictEqual(result, tmp)
171
- } finally {
172
- fs.rmSync(tmp, { recursive: true })
173
- }
142
+ it('defaults to cwd when no argument given', () => {
143
+ assert.strictEqual(paths.find_project_root(), path.resolve(process.cwd()))
174
144
  })
175
145
  })
176
146
  })
package/src/index.js CHANGED
@@ -62,6 +62,7 @@ const parse_args = (argv) => {
62
62
  const SHORT_FLAGS = {
63
63
  g: 'global',
64
64
  y: 'yes',
65
+ w: 'workspace',
65
66
  }
66
67
 
67
68
  const normalize_flags = (flags) => {