happyskills 0.35.4 → 0.36.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,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.36.0] - 2026-04-17
11
+
12
+ ### Added
13
+ - Add 6 dependency validation rules to `validate` command: `dep-self-reference` (skill depends on itself), `dep-exists` (all declared deps exist in registry), `dep-visibility-direct` (public skill's direct deps must be public), `dep-visibility-transitive` (full transitive tree visibility check), `dep-visibility-workspace` (warning for workspace-scoped deps on public skills), `dep-circular` (DFS cycle detection on the full dependency tree). Registry checks run when authenticated; local-only checks run otherwise with an info message suggesting login.
14
+ - Add `extract_root_cause()` and `format_error_chain()` utilities in `utils/errors.js` for cleaner error diagnostics across all commands
15
+
16
+ ### Changed
17
+ - Replace ad-hoc dependency existence check in `publish` with the new validation rules — dependency visibility errors now block publish with clear, actionable messages
18
+ - Change `install` resolver to return `{ packages, skipped }` instead of a flat packages array. Installer prints categorized warnings for skipped deps after install: access-denied suggests `happyskills login`, not-found lists missing skill names. `skipped_deps` is included in the result object and JSON output.
19
+ - Show root cause in `install` error messages (e.g. "Access denied: nicolasdao/init-mission") instead of the generic wrapper ("Install failed"). JSON mode error output now includes a `details` array with the full error chain.
20
+
21
+ ### Fixed
22
+ - Fix `search --workspace <slug>` triggering AuthError when not logged in — only `--mine` and `--personal` flags require authentication; `--workspace` returns public skills without auth
23
+
24
+ ## [0.35.5] - 2026-04-11
25
+
26
+ ### Fixed
27
+ - Fix kits being symlinked to agent folders during `install`, `refresh`, and `enable` — kits are meta-packages not invocable by agents, only their dependency skills should be linked
28
+ - Block `enable` command for kits with a clear warning instead of silently creating useless symlinks
29
+
10
30
  ## [0.35.4] - 2026-04-11
11
31
 
12
32
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.35.4",
3
+ "version": "0.36.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
@@ -10,7 +10,7 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
10
10
  if (options.workspace) params.set('workspace', options.workspace)
11
11
  if (options.tags) params.set('tags', options.tags)
12
12
  if (options.type) params.set('type', options.type)
13
- const needs_auth = (options.scope && options.scope !== 'public') || options.workspace
13
+ const needs_auth = options.scope && options.scope !== 'public'
14
14
  const [errors, data] = await client.get(`/repos/search?${params}`, { auth: needs_auth || false })
15
15
  if (errors) throw errors[errors.length - 1]
16
16
  return data
@@ -6,7 +6,7 @@ const { is_skill_enabled } = require('../agents/status')
6
6
  const { file_exists } = require('../utils/fs')
7
7
  const { print_help, print_json, print_success, print_warn, print_info } = require('../ui/output')
8
8
  const { exit_with_error, UsageError } = require('../utils/errors')
9
- const { EXIT_CODES } = require('../constants')
9
+ const { EXIT_CODES, SKILL_TYPES } = require('../constants')
10
10
 
11
11
  const HELP_TEXT = `Usage: happyskills enable <skill> [skill2 ...] [options]
12
12
 
@@ -77,6 +77,13 @@ const run = (args) => catch_errors('Enable failed', async () => {
77
77
 
78
78
  const { full, short } = resolved
79
79
 
80
+ // Kits are not agent-invocable — skip linking
81
+ if (locked_skills[full]?.type === SKILL_TYPES.KIT) {
82
+ print_warn(`${full} is a kit — kits are not linked to agent folders`)
83
+ results.push({ skill: full, status: 'kit_skipped' })
84
+ continue
85
+ }
86
+
80
87
  // Verify the skill directory exists on disk
81
88
  const dir = skill_install_dir(base_dir, short)
82
89
  const [, exists] = await file_exists(dir)
@@ -107,7 +107,8 @@ const run = (args) => catch_errors('Install failed', async () => {
107
107
  const version = flag_version || inline_version
108
108
  const [errors, result] = await install(skill, { ...base_options, version })
109
109
  if (errors) {
110
- const msg = errors[0]?.message || errors.message || String(errors)
110
+ const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
111
+ const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
111
112
  print_warn(`Failed to install ${skill}: ${msg}`)
112
113
  failures.push({ skill, error: msg })
113
114
  } else {
@@ -116,7 +117,8 @@ const run = (args) => catch_errors('Install failed', async () => {
116
117
  }
117
118
 
118
119
  if (results.length === 0 && failures.length > 0) {
119
- throw new Error(`All ${failures.length} install(s) failed.`)
120
+ const detail = failures.map(f => `${f.skill}: ${f.error}`).join('; ')
121
+ throw new Error(detail)
120
122
  }
121
123
 
122
124
  if (args.flags.json) {
@@ -125,6 +127,7 @@ const run = (args) => catch_errors('Install failed', async () => {
125
127
  version: result.version,
126
128
  installed: result.installed || [],
127
129
  skipped: result.skipped || [],
130
+ skipped_deps: result.skipped_deps || [],
128
131
  warnings: result.warnings || [],
129
132
  forced: result.forced || []
130
133
  }))
@@ -18,6 +18,7 @@ const { validate_skill_json } = require('../validation/skill_json_rules')
18
18
  const { validate_cross } = require('../validation/cross_rules')
19
19
  const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
20
20
  const { validate_file_sizes } = require('../validation/file_size_rules')
21
+ const { validate_dependencies } = require('../validation/dependency_rules')
21
22
  const { create_spinner } = require('../ui/spinner')
22
23
  const { print_help, print_success, print_error, print_warn, print_hint, print_json, code, summarize_warnings } = require('../ui/output')
23
24
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
@@ -144,21 +145,46 @@ const run = (args) => catch_errors('Publish failed', async () => {
144
145
 
145
146
  if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
146
147
  spinner.update('Checking dependencies...')
147
- const dep_entries = Object.keys(manifest.dependencies)
148
- const dep_results = await Promise.all(dep_entries.map(async (dep) => {
149
- const parts = dep.split('/')
150
- if (parts.length !== 2) return { dep, missing: true }
151
- const [dep_owner, dep_name] = parts
152
- const [err] = await repos_api.get_repo(dep_owner, dep_name, { auth: true })
153
- return { dep, missing: !!err }
154
- }))
155
- const missing = dep_results.filter(r => r.missing)
156
- if (missing.length > 0) {
157
- spinner.fail('Dependency check failed')
158
- for (const { dep } of missing) {
159
- print_warn(`Dependency "${dep}" not found in the registry.`)
148
+ const full_name = `${workspace.slug}/${manifest.name}`
149
+ const [dep_err, dep_results] = await validate_dependencies(dir, manifest, {
150
+ registry: true,
151
+ visibility,
152
+ full_name
153
+ })
154
+ if (!dep_err && dep_results) {
155
+ const dep_errors = dep_results.filter(r => r.severity === 'error')
156
+ const dep_warnings = dep_results.filter(r => r.severity === 'warning')
157
+
158
+ if (dep_errors.length > 0) {
159
+ spinner.fail('Dependency check failed')
160
+ if (is_json_mode()) {
161
+ const structured = dep_errors.map(({ severity, ...rest }) => rest)
162
+ print_json({
163
+ error: {
164
+ code: 'DEPENDENCY_VALIDATION_FAILED',
165
+ message: `${dep_errors.length} dependency error(s) found. Fix these issues and try again.`,
166
+ exit_code: EXIT_CODES.ERROR,
167
+ validation_errors: structured
168
+ }
169
+ })
170
+ return process.exit(EXIT_CODES.ERROR)
171
+ }
172
+ for (const r of dep_errors) {
173
+ print_error(r.message)
174
+ }
175
+ return process.exit(EXIT_CODES.ERROR)
160
176
  }
177
+
178
+ if (dep_warnings.length > 0 && !is_json_mode()) {
179
+ for (const r of dep_warnings) {
180
+ print_warn(r.message)
181
+ }
182
+ }
183
+
184
+ // Accumulate dep warnings for JSON output
185
+ validation_warnings.push(...dep_warnings)
161
186
  }
187
+ spinner.update('Preparing to publish...')
162
188
  }
163
189
 
164
190
  // Read base_commit and merge_parents from lock file for divergence check
@@ -9,7 +9,7 @@ const { green, yellow, red } = require('../ui/colors')
9
9
  const { create_spinner } = require('../ui/spinner')
10
10
  const { exit_with_error } = require('../utils/errors')
11
11
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
12
- const { EXIT_CODES } = require('../constants')
12
+ const { EXIT_CODES, SKILL_TYPES } = require('../constants')
13
13
  const { resolve_agents, verify_and_repair_symlinks } = require('../agents')
14
14
  const { is_skill_enabled } = require('../agents/status')
15
15
 
@@ -123,7 +123,8 @@ const run = (args) => catch_errors('Refresh failed', async () => {
123
123
  let symlink_repairs = []
124
124
  if (detected_agents.length > 0) {
125
125
  const skills_to_check = []
126
- for (const [name] of entries) {
126
+ for (const [name, data] of entries) {
127
+ if (data.type === SKILL_TYPES.KIT) continue
127
128
  const short_name = name.split('/')[1] || name
128
129
  const source_dir = skill_install_dir(base_dir, short_name)
129
130
  const [, enabled] = await is_skill_enabled(short_name, detected_agents, is_global, project_root)
@@ -236,7 +236,7 @@ const run = (args) => catch_errors('Search failed', async () => {
236
236
 
237
237
  const scope = mine ? 'mine' : personal ? 'personal' : undefined
238
238
 
239
- if (has_scope_flag) {
239
+ if (mine || personal) {
240
240
  const [, token_data] = await load_token()
241
241
  if (!token_data) {
242
242
  throw new AuthError('Authentication required. Run `happyskills login` first.')
@@ -6,9 +6,12 @@ const { validate_cross } = require('../validation/cross_rules')
6
6
  const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
7
7
  const { validate_file_sizes } = require('../validation/file_size_rules')
8
8
  const { validate_changelog_version } = require('../validation/changelog_rules')
9
+ const { validate_dependencies } = require('../validation/dependency_rules')
9
10
  const { file_exists, read_json } = require('../utils/fs')
10
- const { skills_dir, find_project_root } = require('../config/paths')
11
- const { print_help, print_json } = require('../ui/output')
11
+ const { skills_dir, find_project_root, lock_root } = require('../config/paths')
12
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
13
+ const { load_token } = require('../auth/token_store')
14
+ const { print_help, print_json, print_info } = require('../ui/output')
12
15
  const { bold, green, yellow, red, dim } = require('../ui/colors')
13
16
  const { exit_with_error, UsageError } = require('../utils/errors')
14
17
  const { EXIT_CODES, SKILL_MD, SKILL_JSON, SKILL_TYPES } = require('../constants')
@@ -154,7 +157,47 @@ const run = (args) => catch_errors('Validate failed', async () => {
154
157
  const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
155
158
  if (cl_err) throw cl_err
156
159
 
157
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results]
160
+ // Dependency validation registry checks when authenticated
161
+ let dep_results = []
162
+ const manifest = json_data.manifest || {}
163
+ if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
164
+ const [, token_data] = await load_token()
165
+ const has_registry = !!token_data
166
+
167
+ // Resolve the full owner/name for registry lookups
168
+ let full_name = null
169
+ let visibility = 'private'
170
+ if (has_registry) {
171
+ const project_root = find_project_root()
172
+ const [, lock_data] = await read_lock(lock_root(is_global, project_root))
173
+ if (lock_data) {
174
+ const all_skills = get_all_locked_skills(lock_data)
175
+ const suffix = `/${skill_name}`
176
+ const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
177
+ if (lock_key) full_name = lock_key
178
+ }
179
+ // Try to get visibility from registry if we have the full name
180
+ if (full_name) {
181
+ const repos_api = require('../api/repos')
182
+ const [owner, name] = full_name.split('/')
183
+ const [, repo_data] = await repos_api.get_repo(owner, name, { auth: true })
184
+ if (repo_data) visibility = repo_data.visibility || 'private'
185
+ }
186
+ }
187
+
188
+ const [dep_err, dep_result] = await validate_dependencies(skill_dir, manifest, {
189
+ registry: has_registry,
190
+ visibility,
191
+ full_name
192
+ })
193
+ if (!dep_err && dep_result) dep_results = dep_result
194
+
195
+ if (!has_registry && !args.flags.json) {
196
+ print_info('Dependency registry checks skipped (not logged in). Log in for full validation.')
197
+ }
198
+ }
199
+
200
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results, ...dep_results]
158
201
  const type_label = is_kit ? ' [kit]' : ''
159
202
 
160
203
  if (args.flags.json) {
@@ -10,7 +10,7 @@ const { read_lock, get_locked_skill } = require('../lock/reader')
10
10
  const { write_lock, update_lock_skills } = require('../lock/writer')
11
11
  const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
12
12
  const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
13
- const { SKILL_JSON } = require('../constants')
13
+ const { SKILL_JSON, SKILL_TYPES } = require('../constants')
14
14
  const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('../agents')
15
15
  const { is_skill_enabled } = require('../agents/status')
16
16
  const { create_spinner } = require('../ui/spinner')
@@ -42,8 +42,8 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
42
42
  if (exists) {
43
43
  const [, valid] = locked.integrity ? await verify_integrity(install_dir, locked.integrity) : [null, true]
44
44
  if (valid !== false) {
45
- // Verify and repair symlinks even for already-installed skills
46
- if (agents.length > 0) {
45
+ // Verify and repair symlinks even for already-installed skills (skip kits — not agent-invocable)
46
+ if (agents.length > 0 && locked.type !== SKILL_TYPES.KIT) {
47
47
  const name = skill.split('/')[1]
48
48
  const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
49
49
  if (enabled) {
@@ -73,14 +73,17 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
73
73
  const spinner = create_spinner(`Resolving dependencies for ${skill}...`)
74
74
 
75
75
  let packages
76
+ let skipped_deps = []
76
77
  if (force) {
77
78
  const [errors, result] = await resolve_with_force(skill, version || 'latest', {})
78
79
  if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
79
- packages = result
80
+ packages = result.packages
81
+ skipped_deps = result.skipped || []
80
82
  } else {
81
83
  const [errors, result] = await resolve(skill, version || 'latest', {})
82
84
  if (errors) { spinner.fail('Resolution failed'); throw e('Resolution failed', errors) }
83
- packages = result
85
+ packages = result.packages
86
+ skipped_deps = result.skipped || []
84
87
  }
85
88
 
86
89
  // Filter out packages already installed at the resolved version (handles @latest efficiently)
@@ -103,10 +106,12 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
103
106
  }
104
107
 
105
108
  if (packages_to_install.length === 0) {
106
- // Verify and repair symlinks for all skipped packages
109
+ // Verify and repair symlinks for all skipped packages (skip kits — not agent-invocable)
107
110
  if (agents.length > 0) {
108
111
  const skills_to_verify = []
109
112
  for (const pkg of packages) {
113
+ const locked = get_locked_skill(lock_data, pkg.skill)
114
+ if (locked?.type === SKILL_TYPES.KIT) continue
110
115
  const name = pkg.skill.split('/')[1]
111
116
  const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
112
117
  if (enabled) {
@@ -190,10 +195,22 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
190
195
  await fs.promises.rename(tmp_path, final_dir)
191
196
  }
192
197
 
198
+ // Read type from each installed package's skill.json (used for linking + lock writing)
199
+ const pkg_types = {}
200
+ for (const { pkg } of downloaded) {
201
+ const name = pkg.skill.split('/')[1]
202
+ const final_dir = skill_install_dir(base_dir, name)
203
+ const pkg_json_path = path.join(final_dir, SKILL_JSON)
204
+ const [, pkg_manifest] = await read_json(pkg_json_path)
205
+ if (pkg_manifest?.type) pkg_types[pkg.skill] = pkg_manifest.type
206
+ }
207
+
193
208
  // Link to detected agents (non-fatal — warnings only)
194
- // Skip linking for skills that were disabled before this install/update
209
+ // Skip linking for kits (not invocable by agents) and skills that were disabled before this install/update
195
210
  if (agents.length > 0) {
196
- const to_link = downloaded.filter(({ pkg }) => !disabled_skills.has(pkg.skill.split('/')[1]))
211
+ const to_link = downloaded.filter(({ pkg }) =>
212
+ pkg_types[pkg.skill] !== SKILL_TYPES.KIT && !disabled_skills.has(pkg.skill.split('/')[1])
213
+ )
197
214
  if (to_link.length > 0) {
198
215
  spinner.update(`Linking to ${agents.length} agent(s)...`)
199
216
  for (const { pkg } of to_link) {
@@ -220,12 +237,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
220
237
  const final_dir = skill_install_dir(base_dir, name)
221
238
  const [, integrity] = await hash_directory(final_dir)
222
239
 
223
- // Read type from the installed package's skill.json
224
- let pkg_type
225
- const pkg_json_path = path.join(final_dir, SKILL_JSON)
226
- const [, pkg_manifest] = await read_json(pkg_json_path)
227
- if (pkg_manifest?.type) pkg_type = pkg_manifest.type
228
-
240
+ const pkg_type = pkg_types[pkg.skill]
229
241
  updates[pkg.skill] = {
230
242
  version: pkg.version,
231
243
  ref: pkg.ref,
@@ -261,6 +273,29 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
261
273
  }
262
274
  }
263
275
 
276
+ // Warn about skipped dependencies (access-denied, not found, etc.)
277
+ if (skipped_deps.length > 0) {
278
+ const access_denied = skipped_deps.filter(s => s.reason === 'access_denied')
279
+ const not_found = skipped_deps.filter(s => s.reason === 'not_found')
280
+ const other = skipped_deps.filter(s => s.reason !== 'access_denied' && s.reason !== 'not_found')
281
+ if (access_denied.length > 0) {
282
+ print_warn(`${access_denied.length} dependenc${access_denied.length === 1 ? 'y' : 'ies'} skipped (private, requires login):`)
283
+ for (const s of access_denied) {
284
+ print_warn(` ${s.skill} (required by ${s.required_by})`)
285
+ }
286
+ print_info(`Log in to install: happyskills login`)
287
+ }
288
+ if (not_found.length > 0) {
289
+ print_warn(`${not_found.length} dependenc${not_found.length === 1 ? 'y' : 'ies'} skipped (not found in registry):`)
290
+ for (const s of not_found) {
291
+ print_warn(` ${s.skill} (required by ${s.required_by})`)
292
+ }
293
+ }
294
+ for (const s of other) {
295
+ print_warn(`Skipped ${s.skill}: ${s.message}`)
296
+ }
297
+ }
298
+
264
299
  const installed_set = new Set(packages_to_install.map(p => p.skill))
265
300
  const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
266
301
  const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
@@ -268,7 +303,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
268
303
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
269
304
 
270
305
  const linked_agents = agents.map(a => a.id)
271
- return { skill, version: packages[0]?.version, no_op: false, installed, skipped, warnings, forced, packages: packages_to_install.length, linked_agents }
306
+ return { skill, version: packages[0]?.version, no_op: false, installed, skipped, skipped_deps, warnings, forced, packages: packages_to_install.length, linked_agents }
272
307
  } catch (err) {
273
308
  await remove_dir(temp_dir)
274
309
  throw err
@@ -12,7 +12,7 @@ const resolve = (skill, version, installed = {}) => catch_errors('Dependency res
12
12
  throw new Error(`Dependency conflicts detected:\n${conflict_msg}\n\nUse --force to install anyway (takes highest version).`)
13
13
  }
14
14
 
15
- return result.packages || []
15
+ return { packages: result.packages || [], skipped: result.skipped || [] }
16
16
  })
17
17
 
18
18
  const resolve_with_force = (skill, version, installed = {}) => catch_errors('Forced resolution failed', async () => {
@@ -30,7 +30,7 @@ const resolve_with_force = (skill, version, installed = {}) => catch_errors('For
30
30
  }
31
31
  }
32
32
 
33
- return packages
33
+ return { packages, skipped: result.skipped || [] }
34
34
  })
35
35
 
36
36
  module.exports = { resolve, resolve_with_force }
@@ -49,15 +49,32 @@ const is_usage_error = (err) => {
49
49
  return false
50
50
  }
51
51
 
52
+ const extract_root_cause = (err) => {
53
+ if (!Array.isArray(err)) return err?.message || 'An unexpected error occurred'
54
+ // Walk the error array to find the deepest, most specific message.
55
+ // catch_errors wraps errors outermost-first: [outermost, ..., root_cause].
56
+ // We prefer the last error (root cause) for the user-facing message,
57
+ // but fall back to the first CliError if one exists.
58
+ const messages = err.map(x => x?.message).filter(Boolean)
59
+ if (messages.length === 0) return 'An unexpected error occurred'
60
+ // The last message is typically the root cause (deepest in the chain)
61
+ return messages[messages.length - 1]
62
+ }
63
+
64
+ const format_error_chain = (err) => {
65
+ if (!Array.isArray(err)) return [err?.stack || err?.message || String(err)]
66
+ return err.map(x => x?.stack || x?.message || String(x)).filter(Boolean)
67
+ }
68
+
52
69
  const get_error_info = (err) => {
53
70
  let target = err
54
71
  if (Array.isArray(err)) {
55
- target = err.find(e => e instanceof CliError) || err[0]
72
+ target = err.find(e => e instanceof CliError) || err[err.length - 1] || err[0]
56
73
  }
57
74
 
58
75
  let code = 'ERROR'
59
76
  let exit_code = EXIT_CODES.ERROR
60
- const message = target?.message || 'An unexpected error occurred'
77
+ const message = extract_root_cause(err)
61
78
 
62
79
  if (target instanceof UsageError) {
63
80
  code = 'USAGE_ERROR'; exit_code = EXIT_CODES.USAGE
@@ -80,7 +97,10 @@ const exit_with_error = (err) => {
80
97
 
81
98
  if (is_json_mode()) {
82
99
  const { code, message, exit_code } = get_error_info(err)
83
- console.log(JSON.stringify({ error: { code, message, exit_code } }, null, 2))
100
+ const chain = format_error_chain(err)
101
+ const json_err = { code, message, exit_code }
102
+ if (chain.length > 1) json_err.details = chain
103
+ console.log(JSON.stringify({ error: json_err }, null, 2))
84
104
  process.exit(exit_code)
85
105
  return
86
106
  }
@@ -89,33 +109,11 @@ const exit_with_error = (err) => {
89
109
  const { write_error_log } = require('./logger')
90
110
 
91
111
  const log_path = is_usage_error(err) ? null : write_error_log(err)
112
+ const { message, exit_code } = get_error_info(err)
92
113
 
93
- if (err instanceof CliError) {
94
- print_error(err.message)
95
- print_log_hint(log_path)
96
- process.exit(err.exit_code)
97
- return
98
- }
99
-
100
- if (Array.isArray(err)) {
101
- for (const e of err) {
102
- if (e instanceof CliError) {
103
- print_error(e.message)
104
- print_log_hint(log_path)
105
- process.exit(e.exit_code)
106
- return
107
- }
108
- }
109
- print_error(err[0]?.message || 'An unexpected error occurred')
110
- print_log_hint(log_path)
111
- process.exit(EXIT_CODES.ERROR)
112
- return
113
- }
114
-
115
- const message = err.message || 'An unexpected error occurred'
116
114
  print_error(message)
117
115
  print_log_hint(log_path)
118
- process.exit(EXIT_CODES.ERROR)
116
+ process.exit(exit_code)
119
117
  }
120
118
 
121
119
  module.exports = { CliError, UsageError, AuthError, NetworkError, ApiError, exit_with_error }
@@ -0,0 +1,309 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { SKILL_JSON } = require('../constants')
3
+
4
+ const RESULT_FILE = SKILL_JSON
5
+
6
+ const result = (field, rule, severity, message, value) => ({
7
+ file: RESULT_FILE, field, rule, severity, message, ...(value !== undefined ? { value } : {})
8
+ })
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Local rules (no API needed)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const check_self_reference = (manifest) => {
15
+ const results = []
16
+ const deps = manifest.dependencies || {}
17
+ const dep_names = Object.keys(deps)
18
+
19
+ if (dep_names.length === 0) {
20
+ results.push(result('dependencies', 'dep-self-reference', 'pass', 'No dependencies declared'))
21
+ return results
22
+ }
23
+
24
+ for (const dep of dep_names) {
25
+ const dep_short = dep.includes('/') ? dep.split('/')[1] : dep
26
+ if (dep_short === manifest.name || dep === manifest.name) {
27
+ results.push(result('dependencies', 'dep-self-reference', 'error',
28
+ `Skill depends on itself: "${dep}". Remove this self-reference from dependencies.`, dep))
29
+ }
30
+ }
31
+
32
+ if (!results.some(r => r.rule === 'dep-self-reference' && r.severity === 'error')) {
33
+ results.push(result('dependencies', 'dep-self-reference', 'pass', 'No self-references in dependencies'))
34
+ }
35
+
36
+ return results
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Registry rules (require API access)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const check_deps_exist = async (manifest, repos_api) => {
44
+ const results = []
45
+ const deps = manifest.dependencies || {}
46
+ const dep_names = Object.keys(deps)
47
+
48
+ if (dep_names.length === 0) return results
49
+
50
+ const checks = await Promise.all(dep_names.map(async (dep) => {
51
+ const parts = dep.split('/')
52
+ if (parts.length !== 2) return { dep, error: `Invalid dependency format: "${dep}". Must be owner/name.` }
53
+ const [owner, name] = parts
54
+ const [err] = await repos_api.get_repo(owner, name, { auth: true })
55
+ return { dep, error: err ? `Dependency "${dep}" not found in the registry.` : null }
56
+ }))
57
+
58
+ for (const { dep, error } of checks) {
59
+ if (error) {
60
+ results.push(result('dependencies', 'dep-exists', 'error', error, dep))
61
+ } else {
62
+ results.push(result('dependencies', 'dep-exists', 'pass', `Dependency "${dep}" exists`, dep))
63
+ }
64
+ }
65
+
66
+ return results
67
+ }
68
+
69
+ const check_visibility_direct = async (manifest, visibility, repos_api) => {
70
+ const results = []
71
+ const deps = manifest.dependencies || {}
72
+ const dep_names = Object.keys(deps)
73
+
74
+ if (dep_names.length === 0 || visibility !== 'public') return results
75
+
76
+ const checks = await Promise.all(dep_names.map(async (dep) => {
77
+ const parts = dep.split('/')
78
+ if (parts.length !== 2) return { dep, visibility: null, error: true }
79
+ const [owner, name] = parts
80
+ const [err, repo] = await repos_api.get_repo(owner, name, { auth: true })
81
+ if (err) return { dep, visibility: null, error: true }
82
+ return { dep, visibility: repo.visibility, error: false }
83
+ }))
84
+
85
+ for (const check of checks) {
86
+ if (check.error) continue // already reported by dep-exists
87
+
88
+ if (check.visibility === 'private') {
89
+ results.push(result('dependencies', 'dep-visibility-direct', 'error',
90
+ `Visibility mismatch: this skill is public but depends on "${check.dep}" which is private. ` +
91
+ `Unauthenticated users will not be able to install this skill. ` +
92
+ `To fix: make "${check.dep}" public, or remove it from dependencies.`,
93
+ check.dep))
94
+ } else if (check.visibility === 'workspace') {
95
+ results.push(result('dependencies', 'dep-visibility-workspace', 'warning',
96
+ `Visibility concern: this skill is public but depends on "${check.dep}" which is workspace-scoped. ` +
97
+ `Only workspace members will be able to install this skill. ` +
98
+ `To fix: make "${check.dep}" public for full availability.`,
99
+ check.dep))
100
+ } else {
101
+ results.push(result('dependencies', 'dep-visibility-direct', 'pass',
102
+ `Dependency "${check.dep}" is public`, check.dep))
103
+ }
104
+ }
105
+
106
+ return results
107
+ }
108
+
109
+ const check_visibility_transitive = async (manifest, visibility, repos_api) => {
110
+ const results = []
111
+ const deps = manifest.dependencies || {}
112
+
113
+ if (Object.keys(deps).length === 0 || visibility !== 'public') return results
114
+
115
+ // Use resolve-dependencies to get the full transitive tree
116
+ const skill_name = manifest._full_name
117
+ if (!skill_name) return results // can't check without full name
118
+
119
+ const [err, data] = await repos_api.resolve_dependencies(skill_name, manifest.version || 'latest', {})
120
+ if (err) {
121
+ results.push(result('dependencies', 'dep-visibility-transitive', 'warning',
122
+ 'Could not resolve full dependency tree to check transitive visibility. ' +
123
+ 'Run this check again when connected to the registry.'))
124
+ return results
125
+ }
126
+
127
+ const packages = data.packages || []
128
+ const skipped = data.skipped || []
129
+
130
+ // Check transitive packages returned by the server for non-public visibility
131
+ const direct_deps = new Set(Object.keys(deps))
132
+ for (const pkg of packages) {
133
+ if (pkg.skill === skill_name) continue // skip self
134
+ if (direct_deps.has(pkg.skill)) continue // already checked by dep-visibility-direct
135
+ if (pkg.visibility && pkg.visibility !== 'public') {
136
+ const chain = _build_chain_from_packages(skill_name, pkg.skill, packages)
137
+ const chain_str = chain.join(' → ')
138
+ results.push(result('dependencies', 'dep-visibility-transitive', 'error',
139
+ `Visibility mismatch in dependency tree: "${pkg.skill}" is ${pkg.visibility}. ` +
140
+ `Chain: ${chain_str}. ` +
141
+ `Unauthenticated users will not be able to install this skill. ` +
142
+ `To fix: make "${pkg.skill}" public, or remove it from "${chain[chain.length - 2]}"'s dependencies.`,
143
+ pkg.skill))
144
+ }
145
+ }
146
+
147
+ // Skipped deps (access denied, not found) are also visibility issues
148
+ for (const s of skipped) {
149
+ if (s.reason === 'access_denied') {
150
+ results.push(result('dependencies', 'dep-visibility-transitive', 'error',
151
+ `Visibility mismatch in dependency tree: "${s.skill}" is ${s.visibility || 'private'} ` +
152
+ `(required by ${s.required_by}). ` +
153
+ `Unauthenticated users will not be able to install this skill. ` +
154
+ `To fix: make "${s.skill}" public, or remove it from "${s.required_by}"'s dependencies.`,
155
+ s.skill))
156
+ } else if (s.reason === 'not_found') {
157
+ results.push(result('dependencies', 'dep-visibility-transitive', 'error',
158
+ `Missing dependency in tree: "${s.skill}" not found in registry ` +
159
+ `(required by ${s.required_by}).`,
160
+ s.skill))
161
+ }
162
+ }
163
+
164
+ if (!results.some(r => r.rule === 'dep-visibility-transitive' && r.severity !== 'pass')) {
165
+ results.push(result('dependencies', 'dep-visibility-transitive', 'pass',
166
+ 'All transitive dependencies are public'))
167
+ }
168
+
169
+ return results
170
+ }
171
+
172
+ const check_circular = async (manifest, repos_api) => {
173
+ const results = []
174
+ const deps = manifest.dependencies || {}
175
+
176
+ if (Object.keys(deps).length === 0) return results
177
+
178
+ const skill_name = manifest._full_name
179
+ if (!skill_name) return results
180
+
181
+ const [err, data] = await repos_api.resolve_dependencies(skill_name, manifest.version || 'latest', {})
182
+ if (err) return results
183
+
184
+ const packages = data.packages || []
185
+
186
+ // Build adjacency map from declared dependencies
187
+ const adj = {}
188
+ for (const pkg of packages) {
189
+ adj[pkg.skill] = Object.keys(pkg.dependencies || {})
190
+ }
191
+
192
+ // DFS cycle detection
193
+ const WHITE = 0, GRAY = 1, BLACK = 2
194
+ const color = {}
195
+ const parent = {}
196
+ const cycles = []
197
+
198
+ const dfs = (node, path_so_far) => {
199
+ color[node] = GRAY
200
+ for (const neighbor of (adj[node] || [])) {
201
+ if (color[neighbor] === GRAY) {
202
+ // Found a cycle — extract it
203
+ const cycle_start = path_so_far.indexOf(neighbor)
204
+ if (cycle_start >= 0) {
205
+ cycles.push([...path_so_far.slice(cycle_start), neighbor])
206
+ } else {
207
+ cycles.push([node, neighbor])
208
+ }
209
+ } else if ((color[neighbor] || WHITE) === WHITE) {
210
+ dfs(neighbor, [...path_so_far, neighbor])
211
+ }
212
+ }
213
+ color[node] = BLACK
214
+ }
215
+
216
+ for (const node of Object.keys(adj)) {
217
+ if ((color[node] || WHITE) === WHITE) {
218
+ dfs(node, [node])
219
+ }
220
+ }
221
+
222
+ if (cycles.length > 0) {
223
+ for (const cycle of cycles) {
224
+ results.push(result('dependencies', 'dep-circular', 'error',
225
+ `Circular dependency detected: ${cycle.join(' → ')}. ` +
226
+ `Remove one direction of the dependency to break the cycle.`,
227
+ cycle))
228
+ }
229
+ } else {
230
+ results.push(result('dependencies', 'dep-circular', 'pass',
231
+ 'No circular dependencies detected'))
232
+ }
233
+
234
+ return results
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Helpers
239
+ // ---------------------------------------------------------------------------
240
+
241
+ const _build_chain_from_packages = (root, target, packages) => {
242
+ const adj = {}
243
+ for (const pkg of packages) {
244
+ adj[pkg.skill] = Object.keys(pkg.dependencies || {})
245
+ }
246
+
247
+ const queue = [[root]]
248
+ const seen = new Set([root])
249
+ while (queue.length) {
250
+ const path = queue.shift()
251
+ const node = path[path.length - 1]
252
+ if (node === target) return path
253
+ for (const child of (adj[node] || [])) {
254
+ if (!seen.has(child)) {
255
+ seen.add(child)
256
+ queue.push([...path, child])
257
+ }
258
+ }
259
+ }
260
+ return [root, target]
261
+ }
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Main entry point
265
+ // ---------------------------------------------------------------------------
266
+
267
+ /**
268
+ * Validate dependency rules.
269
+ *
270
+ * @param {string} dir - Skill directory path
271
+ * @param {object} manifest - Parsed skill.json
272
+ * @param {object} [options]
273
+ * @param {boolean} [options.registry] - Whether to run registry checks (default: false)
274
+ * @param {string} [options.visibility] - Skill's target visibility ('public'|'private'|'workspace')
275
+ * @param {string} [options.full_name] - Full "owner/name" for registry lookups
276
+ * @param {object} [options.repos_api] - API module override (for testing)
277
+ */
278
+ const validate_dependencies = (dir, manifest, options = {}) =>
279
+ catch_errors('Dependency validation failed', async () => {
280
+ const { registry = false, visibility = 'private', full_name, repos_api: api_override } = options
281
+ const all_results = []
282
+
283
+ // Always run local checks
284
+ all_results.push(...check_self_reference(manifest))
285
+
286
+ if (!registry) {
287
+ return all_results
288
+ }
289
+
290
+ // Registry checks require the API
291
+ const repos_api = api_override || require('../api/repos')
292
+ const enriched = { ...manifest, _full_name: full_name }
293
+
294
+ const [exist_results, vis_direct_results, vis_trans_results, circ_results] = await Promise.all([
295
+ check_deps_exist(enriched, repos_api),
296
+ check_visibility_direct(enriched, visibility, repos_api),
297
+ check_visibility_transitive(enriched, visibility, repos_api),
298
+ check_circular(enriched, repos_api)
299
+ ])
300
+
301
+ all_results.push(...exist_results)
302
+ all_results.push(...vis_direct_results)
303
+ all_results.push(...vis_trans_results)
304
+ all_results.push(...circ_results)
305
+
306
+ return all_results
307
+ })
308
+
309
+ module.exports = { validate_dependencies }
@@ -0,0 +1,231 @@
1
+ 'use strict'
2
+ const { describe, it, beforeEach, afterEach } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+
8
+ const { validate_dependencies } = require('./dependency_rules')
9
+
10
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-validate-deps-'))
11
+
12
+ let tmp
13
+ beforeEach(() => { tmp = make_tmp() })
14
+ afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Local rules (no registry)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ describe('dep-self-reference', () => {
21
+ it('passes when no dependencies', async () => {
22
+ const manifest = { name: 'my-skill', version: '1.0.0' }
23
+ const [err, results] = await validate_dependencies(tmp, manifest)
24
+ assert.ifError(err)
25
+ const check = results.find(r => r.rule === 'dep-self-reference')
26
+ assert.strictEqual(check.severity, 'pass')
27
+ })
28
+
29
+ it('passes when dependencies do not include self', async () => {
30
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/other': '*' } }
31
+ const [err, results] = await validate_dependencies(tmp, manifest)
32
+ assert.ifError(err)
33
+ const check = results.find(r => r.rule === 'dep-self-reference')
34
+ assert.strictEqual(check.severity, 'pass')
35
+ })
36
+
37
+ it('errors when skill depends on itself (short name match)', async () => {
38
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/my-skill': '*' } }
39
+ const [err, results] = await validate_dependencies(tmp, manifest)
40
+ assert.ifError(err)
41
+ const check = results.find(r => r.rule === 'dep-self-reference' && r.severity === 'error')
42
+ assert.ok(check, 'should find self-reference error')
43
+ assert.ok(check.message.includes('depends on itself'))
44
+ })
45
+ })
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Registry rules (mocked API)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const make_mock_api = (repos = {}, resolve_result = null) => ({
52
+ get_repo: async (owner, name, opts) => {
53
+ const key = `${owner}/${name}`
54
+ if (repos[key]) return [null, repos[key]]
55
+ return [new Error(`Not found: ${key}`), null]
56
+ },
57
+ resolve_dependencies: async (skill, version, installed) => {
58
+ if (resolve_result) return [null, resolve_result]
59
+ return [new Error('Not implemented'), null]
60
+ }
61
+ })
62
+
63
+ describe('dep-exists (registry)', () => {
64
+ it('passes when all deps exist', async () => {
65
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/auth': '*' } }
66
+ const api = make_mock_api({ 'acme/auth': { visibility: 'public' } })
67
+ const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, repos_api: api })
68
+ assert.ifError(err)
69
+ const check = results.find(r => r.rule === 'dep-exists' && r.value === 'acme/auth')
70
+ assert.strictEqual(check.severity, 'pass')
71
+ })
72
+
73
+ it('errors when dep is missing', async () => {
74
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/missing': '*' } }
75
+ const api = make_mock_api({})
76
+ const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, repos_api: api })
77
+ assert.ifError(err)
78
+ const check = results.find(r => r.rule === 'dep-exists' && r.value === 'acme/missing')
79
+ assert.strictEqual(check.severity, 'error')
80
+ assert.ok(check.message.includes('not found'))
81
+ })
82
+ })
83
+
84
+ describe('dep-visibility-direct (registry)', () => {
85
+ it('errors when public skill depends on private dep', async () => {
86
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/secret': '*' } }
87
+ const api = make_mock_api({ 'acme/secret': { visibility: 'private' } })
88
+ const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'public', repos_api: api })
89
+ assert.ifError(err)
90
+ const check = results.find(r => r.rule === 'dep-visibility-direct' && r.severity === 'error')
91
+ assert.ok(check, 'should find visibility error')
92
+ assert.ok(check.message.includes('Visibility mismatch'))
93
+ assert.ok(check.message.includes('private'))
94
+ })
95
+
96
+ it('warns when public skill depends on workspace-scoped dep', async () => {
97
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/internal': '*' } }
98
+ const api = make_mock_api({ 'acme/internal': { visibility: 'workspace' } })
99
+ const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'public', repos_api: api })
100
+ assert.ifError(err)
101
+ const check = results.find(r => r.rule === 'dep-visibility-workspace')
102
+ assert.strictEqual(check.severity, 'warning')
103
+ })
104
+
105
+ it('passes when public skill has all public deps', async () => {
106
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/auth': '*' } }
107
+ const api = make_mock_api({ 'acme/auth': { visibility: 'public' } })
108
+ const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'public', repos_api: api })
109
+ assert.ifError(err)
110
+ const check = results.find(r => r.rule === 'dep-visibility-direct')
111
+ assert.strictEqual(check.severity, 'pass')
112
+ })
113
+
114
+ it('skips visibility check for private skills', async () => {
115
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/secret': '*' } }
116
+ const api = make_mock_api({ 'acme/secret': { visibility: 'private' } })
117
+ const [err, results] = await validate_dependencies(tmp, manifest, { registry: true, visibility: 'private', repos_api: api })
118
+ assert.ifError(err)
119
+ const vis_results = results.filter(r => r.rule === 'dep-visibility-direct' || r.rule === 'dep-visibility-workspace')
120
+ assert.strictEqual(vis_results.length, 0)
121
+ })
122
+ })
123
+
124
+ describe('dep-circular (registry)', () => {
125
+ it('detects a simple cycle A → B → A', async () => {
126
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
127
+ const resolve_result = {
128
+ packages: [
129
+ { skill: 'acme/a', version: '1.0.0', dependencies: { 'acme/b': '*' } },
130
+ { skill: 'acme/b', version: '1.0.0', dependencies: { 'acme/a': '*' } }
131
+ ],
132
+ skipped: []
133
+ }
134
+ const api = make_mock_api({ 'acme/b': { visibility: 'public' } }, resolve_result)
135
+ const [err, results] = await validate_dependencies(tmp, manifest, {
136
+ registry: true, full_name: 'acme/a', repos_api: api
137
+ })
138
+ assert.ifError(err)
139
+ const check = results.find(r => r.rule === 'dep-circular' && r.severity === 'error')
140
+ assert.ok(check, 'should detect cycle')
141
+ assert.ok(check.message.includes('Circular dependency'))
142
+ })
143
+
144
+ it('passes when no cycles', async () => {
145
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
146
+ const resolve_result = {
147
+ packages: [
148
+ { skill: 'acme/a', version: '1.0.0', dependencies: { 'acme/b': '*' } },
149
+ { skill: 'acme/b', version: '1.0.0', dependencies: {} }
150
+ ],
151
+ skipped: []
152
+ }
153
+ const api = make_mock_api({ 'acme/b': { visibility: 'public' } }, resolve_result)
154
+ const [err, results] = await validate_dependencies(tmp, manifest, {
155
+ registry: true, full_name: 'acme/a', repos_api: api
156
+ })
157
+ assert.ifError(err)
158
+ const check = results.find(r => r.rule === 'dep-circular')
159
+ assert.strictEqual(check.severity, 'pass')
160
+ })
161
+ })
162
+
163
+ describe('dep-visibility-transitive (registry)', () => {
164
+ it('errors when transitive dep is private', async () => {
165
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
166
+ const resolve_result = {
167
+ packages: [
168
+ { skill: 'acme/a', version: '1.0.0', visibility: 'public', dependencies: { 'acme/b': '*' } },
169
+ { skill: 'acme/b', version: '1.0.0', visibility: 'public', dependencies: { 'acme/c': '*' } },
170
+ { skill: 'acme/c', version: '1.0.0', visibility: 'private', dependencies: {} }
171
+ ],
172
+ skipped: []
173
+ }
174
+ const api = make_mock_api(
175
+ { 'acme/b': { visibility: 'public' } },
176
+ resolve_result
177
+ )
178
+ const [err, results] = await validate_dependencies(tmp, manifest, {
179
+ registry: true, visibility: 'public', full_name: 'acme/a', repos_api: api
180
+ })
181
+ assert.ifError(err)
182
+ const check = results.find(r => r.rule === 'dep-visibility-transitive' && r.severity === 'error')
183
+ assert.ok(check, 'should find transitive visibility error')
184
+ assert.ok(check.message.includes('acme/c'))
185
+ assert.ok(check.message.includes('private'))
186
+ })
187
+
188
+ it('reports skipped (access-denied) deps as visibility errors', async () => {
189
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
190
+ const resolve_result = {
191
+ packages: [
192
+ { skill: 'acme/a', version: '1.0.0', visibility: 'public', dependencies: { 'acme/b': '*' } },
193
+ { skill: 'acme/b', version: '1.0.0', visibility: 'public', dependencies: { 'acme/secret': '*' } }
194
+ ],
195
+ skipped: [
196
+ { skill: 'acme/secret', reason: 'access_denied', message: 'Access denied', required_by: 'acme/b', visibility: 'private' }
197
+ ]
198
+ }
199
+ const api = make_mock_api(
200
+ { 'acme/b': { visibility: 'public' } },
201
+ resolve_result
202
+ )
203
+ const [err, results] = await validate_dependencies(tmp, manifest, {
204
+ registry: true, visibility: 'public', full_name: 'acme/a', repos_api: api
205
+ })
206
+ assert.ifError(err)
207
+ const check = results.find(r => r.rule === 'dep-visibility-transitive' && r.severity === 'error' && r.value === 'acme/secret')
208
+ assert.ok(check, 'should report skipped dep as visibility error')
209
+ })
210
+
211
+ it('passes when all transitive deps are public', async () => {
212
+ const manifest = { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/b': '*' } }
213
+ const resolve_result = {
214
+ packages: [
215
+ { skill: 'acme/a', version: '1.0.0', visibility: 'public', dependencies: { 'acme/b': '*' } },
216
+ { skill: 'acme/b', version: '1.0.0', visibility: 'public', dependencies: {} }
217
+ ],
218
+ skipped: []
219
+ }
220
+ const api = make_mock_api(
221
+ { 'acme/b': { visibility: 'public' } },
222
+ resolve_result
223
+ )
224
+ const [err, results] = await validate_dependencies(tmp, manifest, {
225
+ registry: true, visibility: 'public', full_name: 'acme/a', repos_api: api
226
+ })
227
+ assert.ifError(err)
228
+ const check = results.find(r => r.rule === 'dep-visibility-transitive')
229
+ assert.strictEqual(check.severity, 'pass')
230
+ })
231
+ })