happyskills 0.22.1 → 0.23.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.23.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - 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.
14
+
10
15
  ## [0.22.1] - 2026-03-30
11
16
 
12
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.22.1",
3
+ "version": "0.23.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)",
@@ -360,7 +360,16 @@ const run = (args) => catch_errors('Pull failed', async () => {
360
360
  const [del_err] = await remove_files(skill_dir, classified.remote_only_deleted.map(f => f.path))
361
361
  if (del_err) { spinner.fail('Failed to remove deleted files'); throw del_err[0] }
362
362
 
363
- // Local-only modified/addedkeep (no action needed)
363
+ // Remote-only renamedrename local files
364
+ for (const entry of classified.remote_renamed) {
365
+ const old_full = path.join(skill_dir, entry.old_path)
366
+ const new_full = path.join(skill_dir, entry.new_path)
367
+ const [dir_err] = await ensure_dir(path.dirname(new_full))
368
+ if (dir_err) { spinner.fail(`Failed to create dir for ${entry.new_path}`); throw dir_err[0] }
369
+ await fs.promises.rename(old_full, new_full)
370
+ }
371
+
372
+ // Local-only modified/added/renamed → keep (no action needed)
364
373
  // Local-only deleted → keep deleted (no action needed)
365
374
  if (full_report) {
366
375
  for (const entry of [...classified.local_only_modified, ...classified.local_only_added]) {
@@ -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