happyskills 0.35.5 → 0.37.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,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.37.0] - 2026-04-25
11
+
12
+ ### Added
13
+ - Add unified content diffs to `diff` command — fetches base and local/remote file content for changed files, computes line-by-line unified diffs using the Myers O(ND) algorithm, and includes them in both CLI and JSON output
14
+ - Add `--no-content` flag to `diff` command to skip content diffs and show only the file list (previous behavior)
15
+ - Add `text_diff.js` utility module — lightweight Myers diff with unified diff formatting, no external dependencies
16
+ - Add colored CLI diff output (red/green/cyan) matching standard unified diff conventions
17
+
18
+ ### Changed
19
+ - Change `diff` JSON output to include `diff` field on each changed file entry with the unified diff text. For `both_modified` files, `local_diff` and `remote_diff` fields are provided instead
20
+
21
+ ## [0.36.0] - 2026-04-17
22
+
23
+ ### Added
24
+ - 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.
25
+ - Add `extract_root_cause()` and `format_error_chain()` utilities in `utils/errors.js` for cleaner error diagnostics across all commands
26
+
27
+ ### Changed
28
+ - Replace ad-hoc dependency existence check in `publish` with the new validation rules — dependency visibility errors now block publish with clear, actionable messages
29
+ - 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.
30
+ - 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.
31
+
32
+ ### Fixed
33
+ - Fix `search --workspace <slug>` triggering AuthError when not logged in — only `--mine` and `--personal` flags require authentication; `--workspace` returns public skills without auth
34
+
10
35
  ## [0.35.5] - 2026-04-11
11
36
 
12
37
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.35.5",
3
+ "version": "0.37.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
@@ -2,19 +2,20 @@ const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
4
  const repos_api = require('../api/repos')
5
- const { detect_status } = require('../merge/detector')
6
5
  const { classify_changes } = require('../merge/comparator')
7
6
  const { build_report } = require('../merge/report')
8
7
  const { hash_blob } = require('../utils/git_hash')
8
+ const { unified_diff } = require('../utils/text_diff')
9
9
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
10
10
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
11
11
  const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
12
+ const { red, green, cyan, bold } = require('../ui/colors')
12
13
  const { exit_with_error, UsageError } = require('../utils/errors')
13
14
  const { EXIT_CODES } = require('../constants')
14
15
 
15
16
  const HELP_TEXT = `Usage: happyskills diff <owner/skill> [options]
16
17
 
17
- Show file-level differences for a skill.
18
+ Show file-level differences for a skill with unified diffs.
18
19
 
19
20
  Arguments:
20
21
  owner/skill Skill to diff (required)
@@ -22,6 +23,7 @@ Arguments:
22
23
  Options:
23
24
  --remote Show base vs remote changes (default: local vs base)
24
25
  --full Show three-way diff (base vs local vs remote)
26
+ --no-content Show only file list without content diffs
25
27
  -g, --global Diff globally installed skill
26
28
  --json Output as JSON
27
29
 
@@ -30,7 +32,8 @@ Aliases: d
30
32
  Examples:
31
33
  happyskills diff acme/deploy-aws
32
34
  happyskills diff acme/deploy-aws --remote
33
- happyskills diff acme/deploy-aws --full`
35
+ happyskills diff acme/deploy-aws --full
36
+ happyskills diff acme/deploy-aws --no-content`
34
37
 
35
38
  // ─── Helpers ──────────────────────────────────────────────────────────────────
36
39
 
@@ -64,6 +67,98 @@ const STATUS_LABELS = {
64
67
  local_only_deleted: 'D (local)'
65
68
  }
66
69
 
70
+ const DIFF_CLASSIFICATIONS = new Set([
71
+ 'local_only_modified', 'local_only_added', 'local_only_deleted',
72
+ 'remote_only_modified', 'remote_only_added', 'remote_only_deleted',
73
+ 'both_modified'
74
+ ])
75
+
76
+ // ─── Content diff enrichment ─────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Enrich report file entries with unified diffs for changed files.
80
+ *
81
+ * Memory strategy:
82
+ * - Only decode/read content for files whose SHAs actually differ
83
+ * - Local file reads run concurrently via Promise.all
84
+ * - All content maps are cleared after diffs are computed
85
+ *
86
+ * @param {object} report - The merge report with file entries
87
+ * @param {Array} base_clone_files - Raw clone response files (with base64 content)
88
+ * @param {string|null} skill_dir - Local skill directory (null if local content not needed)
89
+ * @param {Array|null} remote_clone_files - Raw remote clone files (null if not needed)
90
+ */
91
+ const enrich_with_diffs = (report, base_clone_files, skill_dir, remote_clone_files) => catch_errors('Failed to compute content diffs', async () => {
92
+ const changed = report.files.filter(f => DIFF_CLASSIFICATIONS.has(f.classification))
93
+ if (changed.length === 0) return
94
+
95
+ const changed_paths = new Set(changed.map(f => f.path))
96
+
97
+ // Build base content map — only decode changed paths from the clone response
98
+ const base_map = new Map()
99
+ for (const f of (base_clone_files || [])) {
100
+ if (changed_paths.has(f.path) && f.content) {
101
+ base_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
102
+ }
103
+ }
104
+
105
+ // Build remote content map — same selective decode
106
+ const remote_map = new Map()
107
+ for (const f of (remote_clone_files || [])) {
108
+ if (changed_paths.has(f.path) && f.content) {
109
+ remote_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
110
+ }
111
+ }
112
+
113
+ // Read local content concurrently — only files that exist locally
114
+ const local_map = new Map()
115
+ if (skill_dir) {
116
+ const needs_local = changed.filter(f => f.local_sha)
117
+ const results = await Promise.all(needs_local.map(async f => {
118
+ try {
119
+ const content = await fs.promises.readFile(path.join(skill_dir, f.path), 'utf-8')
120
+ return { path: f.path, content }
121
+ } catch {
122
+ return null
123
+ }
124
+ }))
125
+ for (const r of results) {
126
+ if (r) local_map.set(r.path, r.content)
127
+ }
128
+ }
129
+
130
+ // Compute unified diffs — CPU-only, no I/O
131
+ for (const f of changed) {
132
+ const base = base_map.get(f.path) ?? ''
133
+ const local = local_map.get(f.path) ?? ''
134
+ const remote = remote_map.get(f.path) ?? ''
135
+
136
+ switch (f.classification) {
137
+ case 'local_only_modified':
138
+ case 'local_only_added':
139
+ case 'local_only_deleted':
140
+ f.diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
141
+ break
142
+ case 'remote_only_modified':
143
+ case 'remote_only_added':
144
+ case 'remote_only_deleted':
145
+ f.diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
146
+ break
147
+ case 'both_modified':
148
+ f.local_diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
149
+ f.remote_diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
150
+ break
151
+ }
152
+ }
153
+
154
+ // Flush content maps to release memory
155
+ base_map.clear()
156
+ remote_map.clear()
157
+ local_map.clear()
158
+ })
159
+
160
+ // ─── CLI output ──────────────────────────────────────────────────────────────
161
+
67
162
  const print_file_table = (classified) => {
68
163
  const lines = []
69
164
  for (const [category, label] of Object.entries(STATUS_LABELS)) {
@@ -84,6 +179,44 @@ const print_file_table = (classified) => {
84
179
  }
85
180
  }
86
181
 
182
+ const print_colored_diff = (diff_text) => {
183
+ if (!diff_text) return
184
+ for (const line of diff_text.split('\n')) {
185
+ if (line.startsWith('---') || line.startsWith('+++')) {
186
+ console.log(bold(line))
187
+ } else if (line.startsWith('@@')) {
188
+ console.log(cyan(line))
189
+ } else if (line.startsWith('+')) {
190
+ console.log(green(line))
191
+ } else if (line.startsWith('-')) {
192
+ console.log(red(line))
193
+ } else {
194
+ console.log(line)
195
+ }
196
+ }
197
+ }
198
+
199
+ const print_report_diffs = (report) => {
200
+ const has_diffs = report.files.some(f => f.diff || f.local_diff || f.remote_diff)
201
+ if (!has_diffs) return
202
+
203
+ console.log('')
204
+ for (const f of report.files) {
205
+ if (f.diff) {
206
+ print_colored_diff(f.diff)
207
+ console.log('')
208
+ }
209
+ if (f.local_diff) {
210
+ print_colored_diff(f.local_diff)
211
+ console.log('')
212
+ }
213
+ if (f.remote_diff) {
214
+ print_colored_diff(f.remote_diff)
215
+ console.log('')
216
+ }
217
+ }
218
+ }
219
+
87
220
  // ─── Main ─────────────────────────────────────────────────────────────────────
88
221
 
89
222
  const run = (args) => catch_errors('Diff failed', async () => {
@@ -99,6 +232,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
99
232
 
100
233
  const is_global = args.flags.global || false
101
234
  const mode = args.flags.full ? 'full' : args.flags.remote ? 'remote' : 'local'
235
+ const skip_content = args.flags['no-content'] || false
102
236
  const project_root = find_project_root()
103
237
 
104
238
  // Read lock
@@ -114,29 +248,33 @@ const run = (args) => catch_errors('Diff failed', async () => {
114
248
  const base_dir = skills_dir(is_global, project_root)
115
249
  const skill_dir = skill_install_dir(base_dir, repo)
116
250
 
117
- // Always need base files
251
+ // Always need base files — keep full clone response for content diffs
118
252
  const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
119
253
  if (base_err) throw e('Failed to fetch base files', base_err)
120
254
  const base_files = (base_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
121
255
 
122
256
  if (mode === 'local') {
123
- // Local vs base — use classify_changes with base as the "remote" side
124
257
  const [local_err, local_files] = await build_local_entries(skill_dir)
125
258
  if (local_err) throw e('Failed to read local files', local_err)
126
259
 
127
260
  const classified = classify_changes(base_files, local_files, base_files)
261
+ const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
262
+
263
+ if (!skip_content) {
264
+ const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, null)
265
+ if (enrich_err) throw e('Content diff failed', enrich_err)
266
+ }
128
267
 
129
268
  if (args.flags.json) {
130
- const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
131
269
  print_json({ data: { mode, report } })
132
270
  } else {
133
271
  print_file_table(classified)
272
+ print_report_diffs(report)
134
273
  }
135
274
  return
136
275
  }
137
276
 
138
277
  if (mode === 'remote') {
139
- // Base vs remote — get head commit via compare, clone at that commit
140
278
  const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
141
279
  if (cmp_err) throw e('Compare failed', cmp_err)
142
280
 
@@ -145,17 +283,23 @@ const run = (args) => catch_errors('Diff failed', async () => {
145
283
  const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
146
284
 
147
285
  const classified = classify_changes(base_files, base_files, remote_files)
286
+ const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
287
+
288
+ if (!skip_content) {
289
+ const [enrich_err] = await enrich_with_diffs(report, base_clone.files, null, remote_clone.files)
290
+ if (enrich_err) throw e('Content diff failed', enrich_err)
291
+ }
148
292
 
149
293
  if (args.flags.json) {
150
- const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
151
294
  print_json({ data: { mode, report } })
152
295
  } else {
153
296
  print_file_table(classified)
297
+ print_report_diffs(report)
154
298
  }
155
299
  return
156
300
  }
157
301
 
158
- // Full three-way diff — get head commit via compare
302
+ // Full three-way diff
159
303
  const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
160
304
  if (cmp_err) throw e('Compare failed', cmp_err)
161
305
 
@@ -169,10 +313,16 @@ const run = (args) => catch_errors('Diff failed', async () => {
169
313
  const classified = classify_changes(base_files, local_files, remote_files)
170
314
  const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
171
315
 
316
+ if (!skip_content) {
317
+ const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, remote_clone.files)
318
+ if (enrich_err) throw e('Content diff failed', enrich_err)
319
+ }
320
+
172
321
  if (args.flags.json) {
173
322
  print_json({ data: { mode, report } })
174
323
  } else {
175
324
  print_file_table(classified)
325
+ print_report_diffs(report)
176
326
  if (report.summary.conflicted > 0) {
177
327
  console.error('')
178
328
  print_warn(`${report.summary.conflicted} file(s) modified on both sides`)
@@ -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
@@ -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) {
@@ -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)
@@ -270,6 +273,29 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
270
273
  }
271
274
  }
272
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
+
273
299
  const installed_set = new Set(packages_to_install.map(p => p.skill))
274
300
  const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
275
301
  const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
@@ -277,7 +303,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
277
303
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
278
304
 
279
305
  const linked_agents = agents.map(a => a.id)
280
- 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 }
281
307
  } catch (err) {
282
308
  await remove_dir(temp_dir)
283
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 }