happyskills 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.24.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - Add merge commit support to `pull` and `publish` — when `pull` auto-merges local and remote changes without conflicts, the lock file stores `merge_parents` (the old base and remote head). On `publish`, these are sent as `parent_shas` to create a two-parent merge commit, preserving DAG history. Conflicts fall back to rebase semantics (single-parent commit). `merge_parents` is cleared after publish, on fast-forward, or when conflicts exist.
14
+
10
15
  ## [0.23.0] - 2026-03-30
11
16
 
12
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.23.0",
3
+ "version": "0.24.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/push.js CHANGED
@@ -12,13 +12,15 @@ const estimate_payload_size = (files) => {
12
12
  return size
13
13
  }
14
14
 
15
- const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force }, on_progress) =>
15
+ const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas }, on_progress) =>
16
16
  catch_errors('Smart push failed', async () => {
17
17
  const payload_size = estimate_payload_size(files)
18
18
 
19
19
  if (payload_size < DIRECT_PUSH_THRESHOLD) {
20
20
  // Small payload — use direct push
21
- const [err, data] = await repos_api.push(owner, repo, { version, message, files, visibility, base_commit, force })
21
+ const body = { version, message, files, visibility, base_commit, force }
22
+ if (parent_shas) body.parent_shas = parent_shas
23
+ const [err, data] = await repos_api.push(owner, repo, body)
22
24
  if (err) throw e('Direct push failed', err)
23
25
  return data
24
26
  }
@@ -27,9 +29,9 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
27
29
  const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
28
30
 
29
31
  // Step 1: Initiate
30
- const [init_err, init_data] = await initiate_upload(owner, repo, {
31
- version, message, files: file_meta, visibility, base_commit, force
32
- })
32
+ const init_body = { version, message, files: file_meta, visibility, base_commit, force }
33
+ if (parent_shas) init_body.parent_shas = parent_shas
34
+ const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
33
35
  if (init_err) throw e('Upload initiation failed', init_err)
34
36
 
35
37
  const { upload_id, presigned_urls } = init_data
@@ -52,9 +54,9 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
52
54
  if (upload_err) throw e('File uploads failed', upload_err)
53
55
 
54
56
  // Step 3: Complete
55
- const [complete_err, complete_data] = await complete_upload(owner, repo, {
56
- upload_id, version, message, files: file_meta, base_commit, force
57
- })
57
+ const complete_body = { upload_id, version, message, files: file_meta, base_commit, force }
58
+ if (parent_shas) complete_body.parent_shas = parent_shas
59
+ const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
58
60
  if (complete_err) throw e('Upload completion failed', complete_err)
59
61
 
60
62
  return complete_data
@@ -158,17 +158,19 @@ const run = (args) => catch_errors('Publish failed', async () => {
158
158
  }
159
159
  }
160
160
 
161
- // Read base_commit from lock file for divergence check
161
+ // Read base_commit and merge_parents from lock file for divergence check
162
162
  const full_name_pre = `${workspace.slug}/${manifest.name}`
163
163
  const project_root = find_project_root()
164
164
  const [lock_err, lock_data] = await read_lock(project_root)
165
165
  let base_commit = null
166
+ let merge_parents = null
166
167
  if (!lock_err && lock_data) {
167
168
  const all_skills = get_all_locked_skills(lock_data)
168
169
  const suffix = `/${skill_name}`
169
170
  const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
170
171
  if (lock_key && all_skills[lock_key]) {
171
172
  base_commit = all_skills[lock_key].base_commit || null
173
+ merge_parents = all_skills[lock_key].merge_parents || null
172
174
  }
173
175
  }
174
176
 
@@ -187,14 +189,18 @@ const run = (args) => catch_errors('Publish failed', async () => {
187
189
  spinner.update(`Uploading files (${completed}/${total})...`)
188
190
  }
189
191
  const visibility = args.flags.public ? 'public' : 'private'
190
- const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, {
192
+ const push_options = {
191
193
  version: manifest.version,
192
194
  message: `Release ${manifest.version}`,
193
195
  files: skill_files,
194
196
  visibility,
195
197
  base_commit: force ? null : base_commit,
196
198
  force
197
- }, on_progress)
199
+ }
200
+ if (merge_parents && !force) {
201
+ push_options.parent_shas = merge_parents
202
+ }
203
+ const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, push_options, on_progress)
198
204
  if (push_err) {
199
205
  const last = push_err[push_err.length - 1]
200
206
  if (last?.message?.includes('diverged') || last?.message?.includes('DIVERGED')) {
@@ -226,6 +232,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
226
232
  base_integrity: (!hash_err && integrity) ? integrity : null
227
233
  }
228
234
  if (!hash_err && integrity) updated_entry.integrity = integrity
235
+ delete updated_entry.merge_parents
236
+ delete updated_entry.conflict_files
229
237
  const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
230
238
  await write_lock(project_root, updated_skills)
231
239
  } else {
@@ -205,6 +205,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
205
205
  version: cmp_data.head_version || lock_entry.version,
206
206
  ref: clone_data.ref || lock_entry.ref
207
207
  }
208
+ delete updated_entry.merge_parents
209
+ delete updated_entry.conflict_files
208
210
  const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
209
211
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
210
212
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
@@ -440,8 +442,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
440
442
  }
441
443
  if (conflict_files.length > 0) {
442
444
  updated_entry.conflict_files = conflict_files
445
+ delete updated_entry.merge_parents // Conflicts → rebase fallback
443
446
  } else {
444
447
  delete updated_entry.conflict_files
448
+ updated_entry.merge_parents = [lock_entry.base_commit, cmp_data.head_commit]
445
449
  }
446
450
  const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
447
451
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)