happyskills 0.48.0 → 0.49.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.
@@ -0,0 +1,322 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
+ const repos_api = require('../api/repos')
5
+ const snapshot_storage = require('../snapshot/storage')
6
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
7
+ const { write_lock, update_lock_skills } = require('../lock/writer')
8
+ const { hash_directory } = require('../lock/integrity')
9
+ const { skills_dir, skill_install_dir, lock_root, find_project_root } = require('../config/paths')
10
+ const { ensure_dir, read_file } = require('../utils/fs')
11
+ const { unified_diff } = require('../utils/text_diff')
12
+
13
+ // ──────────────────────────────────────────────────────────────────────────────
14
+ // Rebase-style pull (§ 8.3)
15
+ //
16
+ // Differs from the existing 3-way merge: instead of writing conflict markers
17
+ // into files, we capture local edits as patches, fast-forward to the registry
18
+ // HEAD, and reapply the patches. On rejection (the rebased file's expected
19
+ // context no longer matches), emit a structured envelope that gives the
20
+ // operator (LLM or human) enough information to produce a corrected patch
21
+ // without re-reading entire files.
22
+ //
23
+ // Snapshot-first invariant: every rebase begins with a snapshot. On any
24
+ // failure path the operator can restore — the snapshot_id is in every
25
+ // rejection envelope.
26
+ // ──────────────────────────────────────────────────────────────────────────────
27
+
28
+ const decode_b64 = (file) => file ? Buffer.from(file.content, 'base64').toString('utf-8') : null
29
+
30
+ // Read every file in skill_dir relative to skill_dir, returning a map of
31
+ // path → utf-8 content. Skips dotfiles/dotdirs, matching the rest of the
32
+ // codebase's collect_files semantics.
33
+ const read_dir_files = (dir) => catch_errors('Failed to read dir files', async () => {
34
+ const out = new Map()
35
+ const walk = async (cur, prefix) => {
36
+ const items = await fs.promises.readdir(cur, { withFileTypes: true })
37
+ for (const item of items) {
38
+ if (item.name.startsWith('.')) continue
39
+ const rel = prefix ? `${prefix}/${item.name}` : item.name
40
+ const full = path.join(cur, item.name)
41
+ if (item.isDirectory()) {
42
+ await walk(full, rel)
43
+ } else if (item.isFile()) {
44
+ const content = await fs.promises.readFile(full, 'utf-8')
45
+ out.set(rel, content)
46
+ }
47
+ }
48
+ }
49
+ await walk(dir, '')
50
+ return out
51
+ })
52
+
53
+ // Compare base content vs current disk content → patches for files that
54
+ // differ. The result is a list of { path, base_content, local_content,
55
+ // patch } — we keep the raw contents around because the reapply step needs
56
+ // them to detect three-way conflicts cleanly.
57
+ const capture_local_patches = ({ base_files_map, local_files_map }) => {
58
+ const patches = []
59
+ const all_paths = new Set([...base_files_map.keys(), ...local_files_map.keys()])
60
+ for (const p of all_paths) {
61
+ const base = base_files_map.get(p)
62
+ const local = local_files_map.get(p)
63
+ if (base === local) continue
64
+ if (base === undefined && local !== undefined) {
65
+ patches.push({ path: p, type: 'added', base_content: null, local_content: local, patch: unified_diff('', local, `base/${p}`, `local/${p}`) })
66
+ continue
67
+ }
68
+ if (local === undefined && base !== undefined) {
69
+ patches.push({ path: p, type: 'deleted', base_content: base, local_content: null, patch: unified_diff(base, '', `base/${p}`, `local/${p}`) })
70
+ continue
71
+ }
72
+ patches.push({ path: p, type: 'modified', base_content: base, local_content: local, patch: unified_diff(base, local, `base/${p}`, `local/${p}`) })
73
+ }
74
+ return patches
75
+ }
76
+
77
+ // Reapply a captured patch onto a new base file (post-fast-forward content).
78
+ // Strategy:
79
+ // - If the new base content equals the captured base_content → patch
80
+ // trivially applies (we just write local_content back).
81
+ // - If the new base content equals the captured local_content → patch
82
+ // is already a no-op (remote already had our change). Skip.
83
+ // - Otherwise → rejection. Emit a structured rejection entry with both
84
+ // contexts so the operator can re-aim the patch.
85
+ const reapply_patch = (patch, remote_file_content) => {
86
+ if (patch.type === 'added') {
87
+ // File was added locally. If remote also added it, prefer local
88
+ // (this is rebase — local wins on add/add); if remote didn't,
89
+ // just write our addition.
90
+ return { kind: 'apply_local', content: patch.local_content }
91
+ }
92
+ if (patch.type === 'deleted') {
93
+ // File was deleted locally. If remote also deleted it, no-op.
94
+ // If remote modified it, that's a conflict.
95
+ if (remote_file_content === undefined || remote_file_content === null) {
96
+ return { kind: 'apply_delete' }
97
+ }
98
+ return {
99
+ kind: 'reject',
100
+ reason: 'delete_vs_modify',
101
+ expected_context_before: patch.base_content,
102
+ actual_context_before: remote_file_content,
103
+ intended_change_summary: 'Local deleted; remote modified the same file.'
104
+ }
105
+ }
106
+ // Modified locally.
107
+ if (remote_file_content === patch.base_content) {
108
+ return { kind: 'apply_local', content: patch.local_content }
109
+ }
110
+ if (remote_file_content === patch.local_content) {
111
+ // Remote already has the same change we made — patch is a no-op.
112
+ return { kind: 'noop' }
113
+ }
114
+ return {
115
+ kind: 'reject',
116
+ reason: 'context_changed',
117
+ expected_context_before: patch.base_content,
118
+ actual_context_before: remote_file_content,
119
+ intended_change_summary: 'Local modified a file whose remote content has also changed.'
120
+ }
121
+ }
122
+
123
+ const rebase_pull = (skill_name, options = {}) => catch_errors('Pull --rebase failed', async () => {
124
+ if (!skill_name || !skill_name.includes('/')) {
125
+ throw new Error('skill_name must be in owner/name format')
126
+ }
127
+ const project_root = options.project_root || find_project_root()
128
+ const is_global = !!options.is_global
129
+
130
+ const [lock_err, lock_data] = await read_lock(lock_root(is_global, project_root))
131
+ if (lock_err || !lock_data) throw new Error('No lock file found')
132
+ const all = get_all_locked_skills(lock_data)
133
+ const lock_entry = all[skill_name]
134
+ if (!lock_entry) throw new Error(`${skill_name} is not installed`)
135
+ if (!lock_entry.base_commit) throw new Error(`${skill_name} has no base_commit`)
136
+
137
+ const [owner, repo] = skill_name.split('/')
138
+ const base_dir = skills_dir(is_global, project_root)
139
+ const skill_dir = skill_install_dir(base_dir, repo)
140
+
141
+ // Snapshot first — always.
142
+ const [snap_err, snap] = await snapshot_storage.create({
143
+ skill_dir,
144
+ workspace: owner,
145
+ skill: repo,
146
+ lock_entry,
147
+ note: `pre-rebase-pull`,
148
+ is_global,
149
+ project_root
150
+ })
151
+ if (snap_err) throw e('Snapshot failed', snap_err)
152
+
153
+ const restore_and = async (envelope) => {
154
+ await snapshot_storage.restore(snap.snapshot_id, { skill_dir, is_global, project_root })
155
+ return { ...envelope, snapshot_id: snap.snapshot_id }
156
+ }
157
+
158
+ // Compare with remote.
159
+ const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
160
+ if (cmp_err) {
161
+ return await restore_and({
162
+ error: { code: 'COMPARE_FAILED', message: 'Failed to compare with remote' },
163
+ next_step: { action: 'retry_or_abandon' }
164
+ })
165
+ }
166
+
167
+ if (cmp_data.added.length === 0 && cmp_data.removed.length === 0 && cmp_data.modified.length === 0) {
168
+ // Up to date — drop the snapshot (nothing happened).
169
+ await snapshot_storage.remove(snap.snapshot_id, { is_global, project_root })
170
+ return {
171
+ data: { status: 'up_to_date', skill: skill_name },
172
+ next_step: null
173
+ }
174
+ }
175
+
176
+ // Pull base and remote content.
177
+ const [base_clone_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
178
+ if (base_clone_err) {
179
+ return await restore_and({
180
+ error: { code: 'CLONE_BASE_FAILED', message: 'Failed to clone base commit' },
181
+ next_step: { action: 'retry_or_abandon' }
182
+ })
183
+ }
184
+ const [remote_clone_err, remote_clone] = await repos_api.clone(owner, repo, null, { commit: cmp_data.head_commit })
185
+ if (remote_clone_err) {
186
+ return await restore_and({
187
+ error: { code: 'CLONE_REMOTE_FAILED', message: 'Failed to clone remote head' },
188
+ next_step: { action: 'retry_or_abandon' }
189
+ })
190
+ }
191
+
192
+ const base_files_map = new Map((base_clone.files || []).map(f => [f.path, decode_b64(f)]))
193
+ const remote_files_map = new Map((remote_clone.files || []).map(f => [f.path, decode_b64(f)]))
194
+
195
+ // Capture local patches relative to the base commit.
196
+ const [local_err, local_files_map] = await read_dir_files(skill_dir)
197
+ if (local_err) {
198
+ return await restore_and({
199
+ error: { code: 'READ_LOCAL_FAILED', message: 'Failed to read local files' },
200
+ next_step: { action: 'retry_or_abandon' }
201
+ })
202
+ }
203
+ const patches = capture_local_patches({ base_files_map, local_files_map })
204
+
205
+ // Fast-forward: write remote files into skill_dir, deleting anything not in remote.
206
+ // We do this in a staging directory and atomically swap.
207
+ const staging = `${skill_dir}.rebase-staging-${process.pid}-${Date.now()}`
208
+ await fs.promises.mkdir(staging, { recursive: true })
209
+ for (const [p, content] of remote_files_map) {
210
+ const dest = path.join(staging, p)
211
+ await ensure_dir(path.dirname(dest))
212
+ await fs.promises.writeFile(dest, content, 'utf-8')
213
+ }
214
+
215
+ // Reapply each patch.
216
+ const applied = []
217
+ const rejected = []
218
+ for (const patch of patches) {
219
+ const remote_content = remote_files_map.get(patch.path)
220
+ const decision = reapply_patch(patch, remote_content)
221
+ if (decision.kind === 'apply_local') {
222
+ const dest = path.join(staging, patch.path)
223
+ await ensure_dir(path.dirname(dest))
224
+ await fs.promises.writeFile(dest, decision.content, 'utf-8')
225
+ applied.push({ path: patch.path, type: patch.type })
226
+ } else if (decision.kind === 'apply_delete') {
227
+ const dest = path.join(staging, patch.path)
228
+ try { await fs.promises.unlink(dest) } catch { /* not present, fine */ }
229
+ applied.push({ path: patch.path, type: 'deleted' })
230
+ } else if (decision.kind === 'noop') {
231
+ applied.push({ path: patch.path, type: patch.type, noop: true })
232
+ } else if (decision.kind === 'reject') {
233
+ rejected.push({
234
+ file: patch.path,
235
+ patch_type: patch.type,
236
+ reason: decision.reason,
237
+ expected_context_before: decision.expected_context_before,
238
+ actual_context_before: decision.actual_context_before,
239
+ patch: patch.patch,
240
+ intended_change_summary: decision.intended_change_summary
241
+ })
242
+ }
243
+ }
244
+
245
+ if (rejected.length > 0) {
246
+ // Discard staging directory; the snapshot is the canonical safety net
247
+ // and the operator decides what to do next.
248
+ await fs.promises.rm(staging, { recursive: true, force: true })
249
+ // Note: we do NOT restore the snapshot here — the operator may want
250
+ // to inspect the captured patches and rejection context before
251
+ // deciding. Restoration is one explicit `snapshot restore` away.
252
+ return {
253
+ data: {
254
+ operation: 'pull_rebase',
255
+ skill: skill_name,
256
+ fast_forward_to: cmp_data.head_commit,
257
+ patches_applied: applied,
258
+ patches_rejected: rejected,
259
+ snapshot_id: snap.snapshot_id
260
+ },
261
+ next_step: {
262
+ action: 'resolve_patch_rejections',
263
+ context: {
264
+ rejection_count: rejected.length,
265
+ options: ['resolve_and_continue', 'abandon_and_restore'],
266
+ instructions: 'Either resolve each rejection by re-applying the corrected patches yourself, or restore the pre-rebase state with `npx happyskills snapshot restore <snapshot_id>`.'
267
+ }
268
+ },
269
+ error: null
270
+ }
271
+ }
272
+
273
+ // All patches applied cleanly — swap staging into place.
274
+ const trash = `${skill_dir}.rebase-trash-${process.pid}-${Date.now()}`
275
+ await fs.promises.rename(skill_dir, trash)
276
+ try {
277
+ await fs.promises.rename(staging, skill_dir)
278
+ } catch (err) {
279
+ // Roll back.
280
+ await fs.promises.rename(trash, skill_dir).catch(() => {})
281
+ await fs.promises.rm(staging, { recursive: true, force: true })
282
+ return await restore_and({
283
+ error: { code: 'SWAP_FAILED', message: 'Failed to swap rebased content into place' },
284
+ next_step: { action: 'retry_or_abandon' }
285
+ })
286
+ }
287
+ await fs.promises.rm(trash, { recursive: true, force: true }).catch(() => {})
288
+
289
+ // Update lock.
290
+ const [, new_integrity] = await hash_directory(skill_dir)
291
+ const updated_entry = {
292
+ ...lock_entry,
293
+ commit: cmp_data.head_commit,
294
+ base_commit: cmp_data.head_commit,
295
+ integrity: new_integrity || lock_entry.integrity,
296
+ base_integrity: new_integrity || lock_entry.base_integrity,
297
+ version: cmp_data.head_version || lock_entry.version
298
+ }
299
+ delete updated_entry.merge_parents
300
+ delete updated_entry.conflict_files
301
+ const merged = update_lock_skills(lock_data, { [skill_name]: updated_entry })
302
+ await write_lock(lock_root(is_global, project_root), merged)
303
+
304
+ // Success — drop the snapshot.
305
+ await snapshot_storage.remove(snap.snapshot_id, { is_global, project_root })
306
+
307
+ return {
308
+ data: {
309
+ operation: 'pull_rebase',
310
+ status: applied.length > 0 ? 'rebased' : 'fast_forward',
311
+ skill: skill_name,
312
+ fast_forward_to: cmp_data.head_commit,
313
+ patches_applied: applied,
314
+ patches_rejected: [],
315
+ version: cmp_data.head_version || lock_entry.version
316
+ },
317
+ next_step: null,
318
+ error: null
319
+ }
320
+ })
321
+
322
+ module.exports = { rebase_pull, capture_local_patches, reapply_patch, read_dir_files }
@@ -0,0 +1,110 @@
1
+ 'use strict'
2
+ const { describe, it } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+
8
+ const { capture_local_patches, reapply_patch, read_dir_files } = require('./rebase')
9
+
10
+ describe('rebase.capture_local_patches', () => {
11
+ it('identifies modified files and produces a patch', () => {
12
+ const base = new Map([['SKILL.md', 'one\ntwo\n'], ['skill.json', '{"v":1}']])
13
+ const local = new Map([['SKILL.md', 'one\ntwo\nthree\n'], ['skill.json', '{"v":1}']])
14
+ const patches = capture_local_patches({ base_files_map: base, local_files_map: local })
15
+ assert.strictEqual(patches.length, 1)
16
+ assert.strictEqual(patches[0].path, 'SKILL.md')
17
+ assert.strictEqual(patches[0].type, 'modified')
18
+ assert.ok(patches[0].patch.includes('+three'), 'unified diff should show the added line')
19
+ })
20
+
21
+ it('identifies added files', () => {
22
+ const base = new Map([['SKILL.md', 'body']])
23
+ const local = new Map([['SKILL.md', 'body'], ['references/new.md', 'new']])
24
+ const patches = capture_local_patches({ base_files_map: base, local_files_map: local })
25
+ assert.strictEqual(patches.length, 1)
26
+ assert.strictEqual(patches[0].path, 'references/new.md')
27
+ assert.strictEqual(patches[0].type, 'added')
28
+ assert.strictEqual(patches[0].local_content, 'new')
29
+ assert.strictEqual(patches[0].base_content, null)
30
+ })
31
+
32
+ it('identifies deleted files', () => {
33
+ const base = new Map([['SKILL.md', 'body'], ['gone.md', 'goodbye']])
34
+ const local = new Map([['SKILL.md', 'body']])
35
+ const patches = capture_local_patches({ base_files_map: base, local_files_map: local })
36
+ assert.strictEqual(patches.length, 1)
37
+ assert.strictEqual(patches[0].path, 'gone.md')
38
+ assert.strictEqual(patches[0].type, 'deleted')
39
+ })
40
+
41
+ it('returns no patches when base and local are identical', () => {
42
+ const same = new Map([['SKILL.md', 'body'], ['skill.json', '{}']])
43
+ const patches = capture_local_patches({ base_files_map: same, local_files_map: same })
44
+ assert.deepEqual(patches, [])
45
+ })
46
+ })
47
+
48
+ describe('rebase.reapply_patch', () => {
49
+ it('applies a modified patch when remote content equals base (clean apply)', () => {
50
+ const patch = { path: 'SKILL.md', type: 'modified', base_content: 'a\nb\nc\n', local_content: 'a\nb\nc\nd\n' }
51
+ const remote = 'a\nb\nc\n'
52
+ const decision = reapply_patch(patch, remote)
53
+ assert.strictEqual(decision.kind, 'apply_local')
54
+ assert.strictEqual(decision.content, 'a\nb\nc\nd\n')
55
+ })
56
+
57
+ it('returns noop when remote already has the same change', () => {
58
+ const patch = { path: 'SKILL.md', type: 'modified', base_content: 'a\n', local_content: 'a\nb\n' }
59
+ const remote = 'a\nb\n'
60
+ const decision = reapply_patch(patch, remote)
61
+ assert.strictEqual(decision.kind, 'noop')
62
+ })
63
+
64
+ it('rejects when remote content differs from both base and local (context change)', () => {
65
+ const patch = { path: 'SKILL.md', type: 'modified', base_content: 'a\n', local_content: 'a\nb\n' }
66
+ const remote = 'a\nx\ny\n'
67
+ const decision = reapply_patch(patch, remote)
68
+ assert.strictEqual(decision.kind, 'reject')
69
+ assert.strictEqual(decision.reason, 'context_changed')
70
+ assert.strictEqual(decision.expected_context_before, 'a\n')
71
+ assert.strictEqual(decision.actual_context_before, 'a\nx\ny\n')
72
+ })
73
+
74
+ it('writes added file unconditionally for add patches', () => {
75
+ const patch = { path: 'new.md', type: 'added', base_content: null, local_content: 'fresh\n' }
76
+ const decision = reapply_patch(patch, undefined)
77
+ assert.strictEqual(decision.kind, 'apply_local')
78
+ assert.strictEqual(decision.content, 'fresh\n')
79
+ })
80
+
81
+ it('deletes the file when both sides deleted it', () => {
82
+ const patch = { path: 'gone.md', type: 'deleted', base_content: 'x', local_content: null }
83
+ const decision = reapply_patch(patch, undefined)
84
+ assert.strictEqual(decision.kind, 'apply_delete')
85
+ })
86
+
87
+ it('rejects delete-vs-modify (local deleted; remote modified)', () => {
88
+ const patch = { path: 'gone.md', type: 'deleted', base_content: 'x', local_content: null }
89
+ const decision = reapply_patch(patch, 'modified-on-remote')
90
+ assert.strictEqual(decision.kind, 'reject')
91
+ assert.strictEqual(decision.reason, 'delete_vs_modify')
92
+ })
93
+ })
94
+
95
+ describe('rebase.read_dir_files', () => {
96
+ it('reads all non-dot files recursively', async () => {
97
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-rebase-readdir-'))
98
+ try {
99
+ fs.mkdirSync(path.join(root, 'references'), { recursive: true })
100
+ fs.writeFileSync(path.join(root, 'SKILL.md'), 'top')
101
+ fs.writeFileSync(path.join(root, 'references', 'foo.md'), 'nested')
102
+ fs.mkdirSync(path.join(root, '.tmp'), { recursive: true })
103
+ fs.writeFileSync(path.join(root, '.tmp', 'ignore.txt'), 'should be skipped')
104
+ const [, files] = await read_dir_files(root)
105
+ assert.strictEqual(files.size, 2)
106
+ assert.strictEqual(files.get('SKILL.md'), 'top')
107
+ assert.strictEqual(files.get('references/foo.md'), 'nested')
108
+ } finally { fs.rmSync(root, { recursive: true, force: true }) }
109
+ })
110
+ })
@@ -0,0 +1,31 @@
1
+ const path = require('path')
2
+ const { home_dir } = require('../config/paths')
3
+
4
+ // Snapshots are local-only artifacts. Project snapshots live under the project
5
+ // root; global snapshots live under the user's home so global-installed skills
6
+ // have a parallel safety net. Workspace is encoded into the path so a skill
7
+ // short-name collision across workspaces is impossible.
8
+ const snapshots_root = (is_global, project_root) => {
9
+ if (is_global) return path.join(home_dir, '.happyskills', 'snapshots')
10
+ return path.join(project_root || process.cwd(), '.happyskills', 'snapshots')
11
+ }
12
+
13
+ const skill_snapshots_dir = (is_global, project_root, workspace, skill) => {
14
+ return path.join(snapshots_root(is_global, project_root), workspace, skill)
15
+ }
16
+
17
+ const snapshot_dir = (is_global, project_root, workspace, skill, snapshot_id) => {
18
+ return path.join(skill_snapshots_dir(is_global, project_root, workspace, skill), snapshot_id)
19
+ }
20
+
21
+ const snapshot_manifest_path = (snap_dir) => path.join(snap_dir, 'manifest.json')
22
+
23
+ const snapshot_content_dir = (snap_dir) => path.join(snap_dir, 'content')
24
+
25
+ module.exports = {
26
+ snapshots_root,
27
+ skill_snapshots_dir,
28
+ snapshot_dir,
29
+ snapshot_manifest_path,
30
+ snapshot_content_dir
31
+ }