happyskills 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.21.0] - 2026-03-30
11
+
12
+ ### Changed
13
+ - Upgrade `check` and `refresh` from semver-only to commit-level comparison (`base_commit` vs remote head) for accurate divergence detection — catches divergence even when version strings match (e.g., after force-publish). Falls back to version comparison for old lock files without `base_commit`.
14
+
15
+ ### Added
16
+ - Add `conflicts` status to `check` command — detects unresolved merge conflicts from the lock file's `conflict_files` field without disk I/O. JSON output now includes `conflicts_count`.
17
+ - Add local modification protection to `refresh` — skills with local edits are skipped with a warning and `happyskills pull` suggestion instead of being silently overwritten. JSON output includes `skipped` array with `{ skill, reason, suggestion }` per entry.
18
+
10
19
  ## [0.20.0] - 2026-03-30
11
20
 
12
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.20.0",
3
+ "version": "0.21.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)",
@@ -1,7 +1,6 @@
1
1
  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 repos_api = require('../api/repos')
4
- const { gt } = require('../utils/semver')
5
4
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
6
5
  const { green, yellow, red } = require('../ui/colors')
7
6
  const { exit_with_error } = require('../utils/errors')
@@ -68,13 +67,17 @@ const run = (args) => catch_errors('Check failed', async () => {
68
67
  } else {
69
68
  for (const [name, data] of to_check) {
70
69
  const info = batch_data?.results?.[name]
71
- if (info?.access_denied) {
70
+ const has_conflicts = (data.conflict_files || []).length > 0
71
+ if (has_conflicts) {
72
+ results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts' })
73
+ } else if (info?.access_denied) {
72
74
  results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
73
75
  } else if (!info || !info.latest_version) {
74
76
  results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
75
- } else if (info.latest_version === data.version) {
76
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
77
- } else if (gt(info.latest_version, data.version)) {
77
+ } else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
78
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
79
+ } else if (!data.base_commit && info.latest_version !== data.version) {
80
+ // Fallback to version comparison for old lock files without base_commit
78
81
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
79
82
  } else {
80
83
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
@@ -85,13 +88,15 @@ const run = (args) => catch_errors('Check failed', async () => {
85
88
  if (args.flags.json) {
86
89
  const outdated_count = results.filter(r => r.status === 'outdated').length
87
90
  const up_to_date_count = results.filter(r => r.status === 'up-to-date').length
88
- print_json({ data: { results, outdated_count, up_to_date_count } })
91
+ const conflicts_count = results.filter(r => r.status === 'conflicts').length
92
+ print_json({ data: { results, outdated_count, up_to_date_count, conflicts_count } })
89
93
  return
90
94
  }
91
95
 
92
96
  const status_colors = {
93
97
  'up-to-date': green,
94
98
  'outdated': yellow,
99
+ 'conflicts': red,
95
100
  'no-access': yellow,
96
101
  'error': red,
97
102
  'unknown': (s) => s
@@ -107,11 +112,17 @@ const run = (args) => catch_errors('Check failed', async () => {
107
112
  print_table(['Skill', 'Installed', 'Latest', 'Status'], rows)
108
113
 
109
114
  const outdated = results.filter(r => r.status === 'outdated')
115
+ const conflicts = results.filter(r => r.status === 'conflicts')
110
116
  const no_access = results.filter(r => r.status === 'no-access')
117
+ if (conflicts.length > 0) {
118
+ console.log()
119
+ print_warn(`${conflicts.length} skill(s) have unresolved merge conflicts.`)
120
+ print_hint(`Run ${code('happyskills status')} for details.`)
121
+ }
111
122
  if (outdated.length > 0) {
112
123
  console.log()
113
124
  print_info(`Run ${code('happyskills update')} to upgrade ${outdated.length} skill(s).`)
114
- } else if (results.every(r => r.status === 'up-to-date')) {
125
+ } else if (conflicts.length === 0 && results.every(r => r.status === 'up-to-date')) {
115
126
  console.log()
116
127
  print_success('All skills are up to date.')
117
128
  }
@@ -1,13 +1,13 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { install } = require('../engine/installer')
3
3
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
4
+ const { detect_status } = require('../merge/detector')
4
5
  const repos_api = require('../api/repos')
5
- const { gt } = require('../utils/semver')
6
6
  const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
7
7
  const { green, yellow, red } = require('../ui/colors')
8
8
  const { create_spinner } = require('../ui/spinner')
9
9
  const { exit_with_error } = require('../utils/errors')
10
- const { find_project_root, lock_root } = require('../config/paths')
10
+ const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
11
11
  const { EXIT_CODES } = require('../constants')
12
12
 
13
13
  const HELP_TEXT = `Usage: happyskills refresh [options]
@@ -78,9 +78,10 @@ const run = (args) => catch_errors('Refresh failed', async () => {
78
78
  results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
79
79
  } else if (!info || !info.latest_version) {
80
80
  results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
81
- } else if (info.latest_version === data.version) {
82
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
83
- } else if (gt(info.latest_version, data.version)) {
81
+ } else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
82
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
83
+ } else if (!data.base_commit && info.latest_version !== data.version) {
84
+ // Fallback to version comparison for old lock files without base_commit
84
85
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
85
86
  } else {
86
87
  results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
@@ -125,12 +126,29 @@ const run = (args) => catch_errors('Refresh failed', async () => {
125
126
  }
126
127
  }
127
128
 
128
- // 5. Update outdated skills
129
+ // 5. Detect local modifications before updating
130
+ const base_dir = skills_dir(is_global, project_root)
131
+ const skipped = []
132
+ const safe_to_update = []
133
+
134
+ for (const r of outdated) {
135
+ const lock_entry = skills[r.skill]
136
+ const short_name = r.skill.split('/')[1] || r.skill
137
+ const dir = skill_install_dir(base_dir, short_name)
138
+ const [, det] = await detect_status(lock_entry, dir)
139
+ if (det?.local_modified) {
140
+ skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
141
+ } else {
142
+ safe_to_update.push(r)
143
+ }
144
+ }
145
+
146
+ // 6. Update safe skills
129
147
  const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
130
148
  const updated = []
131
149
  const update_errors = []
132
150
 
133
- for (const r of outdated) {
151
+ for (const r of safe_to_update) {
134
152
  const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
135
153
  const [errors, result] = await install(r.skill, options)
136
154
  if (errors) {
@@ -144,7 +162,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
144
162
  }
145
163
  }
146
164
 
147
- // 6. Output results
165
+ // 7. Output results
148
166
  if (args.flags.json) {
149
167
  print_json({
150
168
  data: {
@@ -152,6 +170,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
152
170
  outdated_count: outdated.length,
153
171
  up_to_date_count: up_to_date.length,
154
172
  updated,
173
+ skipped,
155
174
  already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
156
175
  errors: update_errors
157
176
  }
@@ -159,6 +178,14 @@ const run = (args) => catch_errors('Refresh failed', async () => {
159
178
  return
160
179
  }
161
180
 
181
+ if (skipped.length > 0) {
182
+ console.log()
183
+ print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
184
+ for (const s of skipped) {
185
+ print_info(` ${s.skill}`)
186
+ }
187
+ print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
188
+ }
162
189
  if (updated.length > 0) {
163
190
  console.log()
164
191
  print_success(`Updated ${updated.length} skill(s).`)