happyskills 0.22.1 → 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,16 @@ 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
+
15
+ ## [0.23.0] - 2026-03-30
16
+
17
+ ### Added
18
+ - Add renamed file detection to `pull` merge — files renamed on one side (same content, different path) are now classified as `remote_renamed`/`local_renamed` instead of delete + add, and applied as renames on disk. Only 1-to-1 SHA matches are detected; ambiguous many-to-many cases are safely skipped.
19
+
10
20
  ## [0.22.1] - 2026-03-30
11
21
 
12
22
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.22.1",
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] }
@@ -360,7 +362,16 @@ const run = (args) => catch_errors('Pull failed', async () => {
360
362
  const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
361
363
  if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
362
364
 
363
- // Local-only modified/addedkeep (no action needed)
365
+ // Remote-only renamedrename local files
366
+ for (const entry of classified.remote_renamed) {
367
+ const old_full = path.join(skill_dir, entry.old_path)
368
+ const new_full = path.join(skill_dir, entry.new_path)
369
+ const [dir_err] = await ensure_dir(path.dirname(new_full))
370
+ if (dir_err) { spinner.fail(`Failed to create dir for ${entry.new_path}`); throw dir_err[0] }
371
+ await fs.promises.rename(old_full, new_full)
372
+ }
373
+
374
+ // Local-only modified/added/renamed → keep (no action needed)
364
375
  // Local-only deleted → keep deleted (no action needed)
365
376
  if (full_report) {
366
377
  for (const entry of [...classified.local_only_modified, ...classified.local_only_added]) {
@@ -431,8 +442,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
431
442
  }
432
443
  if (conflict_files.length > 0) {
433
444
  updated_entry.conflict_files = conflict_files
445
+ delete updated_entry.merge_parents // Conflicts → rebase fallback
434
446
  } else {
435
447
  delete updated_entry.conflict_files
448
+ updated_entry.merge_parents = [lock_entry.base_commit, cmp_data.head_commit]
436
449
  }
437
450
  const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
438
451
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
@@ -7,6 +7,37 @@
7
7
  * All inputs are arrays of { path, sha } entries.
8
8
  */
9
9
 
10
+ /**
11
+ * Detect 1-to-1 renames by matching SHAs between deleted and added files.
12
+ * Ambiguous matches (multiple deletes or adds sharing a SHA) are skipped.
13
+ */
14
+ const detect_renames = (deleted, added, get_del_sha, get_add_sha) => {
15
+ const del_by_sha = new Map()
16
+ for (const entry of deleted) {
17
+ const sha = get_del_sha(entry)
18
+ if (!sha) continue
19
+ del_by_sha.set(sha, del_by_sha.has(sha) ? null : entry)
20
+ }
21
+ const add_by_sha = new Map()
22
+ for (const entry of added) {
23
+ const sha = get_add_sha(entry)
24
+ if (!sha) continue
25
+ add_by_sha.set(sha, add_by_sha.has(sha) ? null : entry)
26
+ }
27
+ const renames = []
28
+ const del_matched = new Set()
29
+ const add_matched = new Set()
30
+ for (const [sha, del_entry] of del_by_sha) {
31
+ if (!del_entry) continue
32
+ const add_entry = add_by_sha.get(sha)
33
+ if (!add_entry) continue
34
+ renames.push({ old_path: del_entry.path, new_path: add_entry.path, sha })
35
+ del_matched.add(del_entry.path)
36
+ add_matched.add(add_entry.path)
37
+ }
38
+ return { renames, del_matched, add_matched }
39
+ }
40
+
10
41
  const classify_changes = (base_files, local_files, remote_files) => {
11
42
  const base_map = new Map(base_files.map(e => [e.path, e.sha]))
12
43
  const local_map = new Map(local_files.map(e => [e.path, e.sha]))
@@ -83,14 +114,26 @@ const classify_changes = (base_files, local_files, remote_files) => {
83
114
  }
84
115
  }
85
116
 
117
+ // Detect renames: 1-to-1 SHA matches between deleted and added on the same side
118
+ const r = detect_renames(
119
+ remote_only_deleted, remote_only_added,
120
+ e => base_map.get(e.path), e => e.remote_sha
121
+ )
122
+ const l = detect_renames(
123
+ local_only_deleted, local_only_added,
124
+ e => base_map.get(e.path), e => e.local_sha
125
+ )
126
+
86
127
  return {
87
128
  remote_only_modified,
88
129
  local_only_modified,
89
130
  both_modified,
90
- remote_only_added,
91
- local_only_added,
92
- remote_only_deleted,
93
- local_only_deleted,
131
+ remote_only_added: remote_only_added.filter(e => !r.add_matched.has(e.path)),
132
+ local_only_added: local_only_added.filter(e => !l.add_matched.has(e.path)),
133
+ remote_only_deleted: remote_only_deleted.filter(e => !r.del_matched.has(e.path)),
134
+ local_only_deleted: local_only_deleted.filter(e => !l.del_matched.has(e.path)),
135
+ remote_renamed: r.renames,
136
+ local_renamed: l.renames,
94
137
  unchanged
95
138
  }
96
139
  }
@@ -158,4 +158,97 @@ describe('classify_changes', () => {
158
158
  assert.strictEqual(r.both_modified.length, 1)
159
159
  assert.deepStrictEqual(r.both_modified[0], { path: 'a.md', base_sha: '111', local_sha: '222', remote_sha: null })
160
160
  })
161
+
162
+ it('detects remote rename (1-to-1 SHA match)', () => {
163
+ const base = [{ path: 'old.md', sha: 'aaa' }]
164
+ const local = [{ path: 'old.md', sha: 'aaa' }]
165
+ const remote = [{ path: 'new.md', sha: 'aaa' }]
166
+ const r = classify_changes(base, local, remote)
167
+ assert.strictEqual(r.remote_renamed.length, 1)
168
+ assert.deepStrictEqual(r.remote_renamed[0], { old_path: 'old.md', new_path: 'new.md', sha: 'aaa' })
169
+ assert.strictEqual(r.remote_only_deleted.length, 0)
170
+ assert.strictEqual(r.remote_only_added.length, 0)
171
+ })
172
+
173
+ it('detects local rename (1-to-1 SHA match)', () => {
174
+ const base = [{ path: 'old.md', sha: 'bbb' }]
175
+ const local = [{ path: 'new.md', sha: 'bbb' }]
176
+ const remote = [{ path: 'old.md', sha: 'bbb' }]
177
+ const r = classify_changes(base, local, remote)
178
+ assert.strictEqual(r.local_renamed.length, 1)
179
+ assert.deepStrictEqual(r.local_renamed[0], { old_path: 'old.md', new_path: 'new.md', sha: 'bbb' })
180
+ assert.strictEqual(r.local_only_deleted.length, 0)
181
+ assert.strictEqual(r.local_only_added.length, 0)
182
+ })
183
+
184
+ it('skips rename detection for many-to-many SHA matches', () => {
185
+ // Two deleted files with same SHA + two added files with same SHA → ambiguous
186
+ const base = [
187
+ { path: 'a.md', sha: 'xxx' },
188
+ { path: 'b.md', sha: 'xxx' },
189
+ ]
190
+ const local = [
191
+ { path: 'a.md', sha: 'xxx' },
192
+ { path: 'b.md', sha: 'xxx' },
193
+ ]
194
+ const remote = [
195
+ { path: 'c.md', sha: 'xxx' },
196
+ { path: 'd.md', sha: 'xxx' },
197
+ ]
198
+ const r = classify_changes(base, local, remote)
199
+ assert.strictEqual(r.remote_renamed.length, 0)
200
+ assert.strictEqual(r.remote_only_deleted.length, 2)
201
+ assert.strictEqual(r.remote_only_added.length, 2)
202
+ })
203
+
204
+ it('skips rename when only one side is ambiguous (two deletes, one add)', () => {
205
+ const base = [
206
+ { path: 'a.md', sha: 'xxx' },
207
+ { path: 'b.md', sha: 'xxx' },
208
+ ]
209
+ const local = [
210
+ { path: 'a.md', sha: 'xxx' },
211
+ { path: 'b.md', sha: 'xxx' },
212
+ ]
213
+ const remote = [{ path: 'c.md', sha: 'xxx' }]
214
+ const r = classify_changes(base, local, remote)
215
+ assert.strictEqual(r.remote_renamed.length, 0)
216
+ assert.strictEqual(r.remote_only_deleted.length, 2)
217
+ assert.strictEqual(r.remote_only_added.length, 1)
218
+ })
219
+
220
+ it('does not detect rename when content changed (different SHAs)', () => {
221
+ const base = [{ path: 'old.md', sha: 'aaa' }]
222
+ const local = [{ path: 'old.md', sha: 'aaa' }]
223
+ const remote = [{ path: 'new.md', sha: 'bbb' }]
224
+ const r = classify_changes(base, local, remote)
225
+ assert.strictEqual(r.remote_renamed.length, 0)
226
+ assert.strictEqual(r.remote_only_deleted.length, 1)
227
+ assert.strictEqual(r.remote_only_added.length, 1)
228
+ })
229
+
230
+ it('handles mixed scenario with renames alongside other changes', () => {
231
+ const base = [
232
+ { path: 'unchanged.md', sha: '000' },
233
+ { path: 'old_name.md', sha: 'rrr' },
234
+ { path: 'remote_mod.md', sha: '111' },
235
+ ]
236
+ const local = [
237
+ { path: 'unchanged.md', sha: '000' },
238
+ { path: 'old_name.md', sha: 'rrr' },
239
+ { path: 'remote_mod.md', sha: '111' },
240
+ ]
241
+ const remote = [
242
+ { path: 'unchanged.md', sha: '000' },
243
+ { path: 'new_name.md', sha: 'rrr' },
244
+ { path: 'remote_mod.md', sha: 'R11' },
245
+ ]
246
+ const r = classify_changes(base, local, remote)
247
+ assert.strictEqual(r.unchanged.length, 1)
248
+ assert.strictEqual(r.remote_renamed.length, 1)
249
+ assert.deepStrictEqual(r.remote_renamed[0], { old_path: 'old_name.md', new_path: 'new_name.md', sha: 'rrr' })
250
+ assert.strictEqual(r.remote_only_modified.length, 1)
251
+ assert.strictEqual(r.remote_only_deleted.length, 0)
252
+ assert.strictEqual(r.remote_only_added.length, 0)
253
+ })
161
254
  })
@@ -14,6 +14,7 @@ const CLASSIFICATIONS = [
14
14
  'remote_only_modified', 'local_only_modified', 'both_modified',
15
15
  'remote_only_added', 'local_only_added',
16
16
  'remote_only_deleted', 'local_only_deleted',
17
+ 'remote_renamed', 'local_renamed',
17
18
  'unchanged'
18
19
  ]
19
20
 
@@ -41,14 +42,17 @@ const build_report = (skill, base_version, remote_version, classified) => {
41
42
  if (is_conflict) conflicted++
42
43
  else auto_merged++
43
44
 
44
- files.push({
45
- path: entry.path,
45
+ const is_rename = classification === 'remote_renamed' || classification === 'local_renamed'
46
+ const file_entry = {
47
+ path: is_rename ? entry.new_path : entry.path,
46
48
  classification,
47
49
  conflict_written: false,
48
- base_sha: entry.base_sha || null,
50
+ base_sha: entry.base_sha || entry.sha || null,
49
51
  local_sha: entry.local_sha || null,
50
52
  remote_sha: entry.remote_sha || null
51
- })
53
+ }
54
+ if (is_rename) file_entry.old_path = entry.old_path
55
+ files.push(file_entry)
52
56
  }
53
57
  }
54
58