happyskills 0.36.0 → 0.38.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.38.0] - 2026-05-01
11
+
12
+ ### Changed
13
+ - **BREAKING:** Make `--limit` required for the `search` command — there is no default. Running `happyskills search ...` without `--limit` now returns a usage error (exit code 2). The previous hidden default of 10 caused AI-agent callers to miss results without realizing a limit was being applied. Affects both smart-search and `--exact` keyword modes. Update help text and all examples to show `--limit` explicitly.
14
+
15
+ ### Fixed
16
+ - Fix `search --workspace <slug>` (in browse mode with no query, and in `--exact` keyword mode) silently excluding private skills the caller has access to. The CLI was omitting the auth token when only `--workspace` was specified without `--mine` or `--personal`, causing the API to fall back to public-only results. The auth token is now always attached when one is available, regardless of the search mode.
17
+
18
+ ## [0.37.0] - 2026-04-25
19
+
20
+ ### Added
21
+ - Add unified content diffs to `diff` command — fetches base and local/remote file content for changed files, computes line-by-line unified diffs using the Myers O(ND) algorithm, and includes them in both CLI and JSON output
22
+ - Add `--no-content` flag to `diff` command to skip content diffs and show only the file list (previous behavior)
23
+ - Add `text_diff.js` utility module — lightweight Myers diff with unified diff formatting, no external dependencies
24
+ - Add colored CLI diff output (red/green/cyan) matching standard unified diff conventions
25
+
26
+ ### Changed
27
+ - Change `diff` JSON output to include `diff` field on each changed file entry with the unified diff text. For `both_modified` files, `local_diff` and `remote_diff` fields are provided instead
28
+
10
29
  ## [0.36.0] - 2026-04-17
11
30
 
12
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.36.0",
3
+ "version": "0.38.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/repos.js CHANGED
@@ -10,8 +10,7 @@ const search = (query, options = {}) => catch_errors('Search failed', async () =
10
10
  if (options.workspace) params.set('workspace', options.workspace)
11
11
  if (options.tags) params.set('tags', options.tags)
12
12
  if (options.type) params.set('type', options.type)
13
- const needs_auth = options.scope && options.scope !== 'public'
14
- const [errors, data] = await client.get(`/repos/search?${params}`, { auth: needs_auth || false })
13
+ const [errors, data] = await client.get(`/repos/search?${params}`)
15
14
  if (errors) throw errors[errors.length - 1]
16
15
  return data
17
16
  })
@@ -2,19 +2,20 @@ const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
4
  const repos_api = require('../api/repos')
5
- const { detect_status } = require('../merge/detector')
6
5
  const { classify_changes } = require('../merge/comparator')
7
6
  const { build_report } = require('../merge/report')
8
7
  const { hash_blob } = require('../utils/git_hash')
8
+ const { unified_diff } = require('../utils/text_diff')
9
9
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
10
10
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
11
11
  const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
12
+ const { red, green, cyan, bold } = require('../ui/colors')
12
13
  const { exit_with_error, UsageError } = require('../utils/errors')
13
14
  const { EXIT_CODES } = require('../constants')
14
15
 
15
16
  const HELP_TEXT = `Usage: happyskills diff <owner/skill> [options]
16
17
 
17
- Show file-level differences for a skill.
18
+ Show file-level differences for a skill with unified diffs.
18
19
 
19
20
  Arguments:
20
21
  owner/skill Skill to diff (required)
@@ -22,6 +23,7 @@ Arguments:
22
23
  Options:
23
24
  --remote Show base vs remote changes (default: local vs base)
24
25
  --full Show three-way diff (base vs local vs remote)
26
+ --no-content Show only file list without content diffs
25
27
  -g, --global Diff globally installed skill
26
28
  --json Output as JSON
27
29
 
@@ -30,7 +32,8 @@ Aliases: d
30
32
  Examples:
31
33
  happyskills diff acme/deploy-aws
32
34
  happyskills diff acme/deploy-aws --remote
33
- happyskills diff acme/deploy-aws --full`
35
+ happyskills diff acme/deploy-aws --full
36
+ happyskills diff acme/deploy-aws --no-content`
34
37
 
35
38
  // ─── Helpers ──────────────────────────────────────────────────────────────────
36
39
 
@@ -64,6 +67,98 @@ const STATUS_LABELS = {
64
67
  local_only_deleted: 'D (local)'
65
68
  }
66
69
 
70
+ const DIFF_CLASSIFICATIONS = new Set([
71
+ 'local_only_modified', 'local_only_added', 'local_only_deleted',
72
+ 'remote_only_modified', 'remote_only_added', 'remote_only_deleted',
73
+ 'both_modified'
74
+ ])
75
+
76
+ // ─── Content diff enrichment ─────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Enrich report file entries with unified diffs for changed files.
80
+ *
81
+ * Memory strategy:
82
+ * - Only decode/read content for files whose SHAs actually differ
83
+ * - Local file reads run concurrently via Promise.all
84
+ * - All content maps are cleared after diffs are computed
85
+ *
86
+ * @param {object} report - The merge report with file entries
87
+ * @param {Array} base_clone_files - Raw clone response files (with base64 content)
88
+ * @param {string|null} skill_dir - Local skill directory (null if local content not needed)
89
+ * @param {Array|null} remote_clone_files - Raw remote clone files (null if not needed)
90
+ */
91
+ const enrich_with_diffs = (report, base_clone_files, skill_dir, remote_clone_files) => catch_errors('Failed to compute content diffs', async () => {
92
+ const changed = report.files.filter(f => DIFF_CLASSIFICATIONS.has(f.classification))
93
+ if (changed.length === 0) return
94
+
95
+ const changed_paths = new Set(changed.map(f => f.path))
96
+
97
+ // Build base content map — only decode changed paths from the clone response
98
+ const base_map = new Map()
99
+ for (const f of (base_clone_files || [])) {
100
+ if (changed_paths.has(f.path) && f.content) {
101
+ base_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
102
+ }
103
+ }
104
+
105
+ // Build remote content map — same selective decode
106
+ const remote_map = new Map()
107
+ for (const f of (remote_clone_files || [])) {
108
+ if (changed_paths.has(f.path) && f.content) {
109
+ remote_map.set(f.path, Buffer.from(f.content, 'base64').toString('utf-8'))
110
+ }
111
+ }
112
+
113
+ // Read local content concurrently — only files that exist locally
114
+ const local_map = new Map()
115
+ if (skill_dir) {
116
+ const needs_local = changed.filter(f => f.local_sha)
117
+ const results = await Promise.all(needs_local.map(async f => {
118
+ try {
119
+ const content = await fs.promises.readFile(path.join(skill_dir, f.path), 'utf-8')
120
+ return { path: f.path, content }
121
+ } catch {
122
+ return null
123
+ }
124
+ }))
125
+ for (const r of results) {
126
+ if (r) local_map.set(r.path, r.content)
127
+ }
128
+ }
129
+
130
+ // Compute unified diffs — CPU-only, no I/O
131
+ for (const f of changed) {
132
+ const base = base_map.get(f.path) ?? ''
133
+ const local = local_map.get(f.path) ?? ''
134
+ const remote = remote_map.get(f.path) ?? ''
135
+
136
+ switch (f.classification) {
137
+ case 'local_only_modified':
138
+ case 'local_only_added':
139
+ case 'local_only_deleted':
140
+ f.diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
141
+ break
142
+ case 'remote_only_modified':
143
+ case 'remote_only_added':
144
+ case 'remote_only_deleted':
145
+ f.diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
146
+ break
147
+ case 'both_modified':
148
+ f.local_diff = unified_diff(base, local, `base/${f.path}`, `local/${f.path}`)
149
+ f.remote_diff = unified_diff(base, remote, `base/${f.path}`, `remote/${f.path}`)
150
+ break
151
+ }
152
+ }
153
+
154
+ // Flush content maps to release memory
155
+ base_map.clear()
156
+ remote_map.clear()
157
+ local_map.clear()
158
+ })
159
+
160
+ // ─── CLI output ──────────────────────────────────────────────────────────────
161
+
67
162
  const print_file_table = (classified) => {
68
163
  const lines = []
69
164
  for (const [category, label] of Object.entries(STATUS_LABELS)) {
@@ -84,6 +179,44 @@ const print_file_table = (classified) => {
84
179
  }
85
180
  }
86
181
 
182
+ const print_colored_diff = (diff_text) => {
183
+ if (!diff_text) return
184
+ for (const line of diff_text.split('\n')) {
185
+ if (line.startsWith('---') || line.startsWith('+++')) {
186
+ console.log(bold(line))
187
+ } else if (line.startsWith('@@')) {
188
+ console.log(cyan(line))
189
+ } else if (line.startsWith('+')) {
190
+ console.log(green(line))
191
+ } else if (line.startsWith('-')) {
192
+ console.log(red(line))
193
+ } else {
194
+ console.log(line)
195
+ }
196
+ }
197
+ }
198
+
199
+ const print_report_diffs = (report) => {
200
+ const has_diffs = report.files.some(f => f.diff || f.local_diff || f.remote_diff)
201
+ if (!has_diffs) return
202
+
203
+ console.log('')
204
+ for (const f of report.files) {
205
+ if (f.diff) {
206
+ print_colored_diff(f.diff)
207
+ console.log('')
208
+ }
209
+ if (f.local_diff) {
210
+ print_colored_diff(f.local_diff)
211
+ console.log('')
212
+ }
213
+ if (f.remote_diff) {
214
+ print_colored_diff(f.remote_diff)
215
+ console.log('')
216
+ }
217
+ }
218
+ }
219
+
87
220
  // ─── Main ─────────────────────────────────────────────────────────────────────
88
221
 
89
222
  const run = (args) => catch_errors('Diff failed', async () => {
@@ -99,6 +232,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
99
232
 
100
233
  const is_global = args.flags.global || false
101
234
  const mode = args.flags.full ? 'full' : args.flags.remote ? 'remote' : 'local'
235
+ const skip_content = args.flags['no-content'] || false
102
236
  const project_root = find_project_root()
103
237
 
104
238
  // Read lock
@@ -114,29 +248,33 @@ const run = (args) => catch_errors('Diff failed', async () => {
114
248
  const base_dir = skills_dir(is_global, project_root)
115
249
  const skill_dir = skill_install_dir(base_dir, repo)
116
250
 
117
- // Always need base files
251
+ // Always need base files — keep full clone response for content diffs
118
252
  const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
119
253
  if (base_err) throw e('Failed to fetch base files', base_err)
120
254
  const base_files = (base_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
121
255
 
122
256
  if (mode === 'local') {
123
- // Local vs base — use classify_changes with base as the "remote" side
124
257
  const [local_err, local_files] = await build_local_entries(skill_dir)
125
258
  if (local_err) throw e('Failed to read local files', local_err)
126
259
 
127
260
  const classified = classify_changes(base_files, local_files, base_files)
261
+ const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
262
+
263
+ if (!skip_content) {
264
+ const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, null)
265
+ if (enrich_err) throw e('Content diff failed', enrich_err)
266
+ }
128
267
 
129
268
  if (args.flags.json) {
130
- const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
131
269
  print_json({ data: { mode, report } })
132
270
  } else {
133
271
  print_file_table(classified)
272
+ print_report_diffs(report)
134
273
  }
135
274
  return
136
275
  }
137
276
 
138
277
  if (mode === 'remote') {
139
- // Base vs remote — get head commit via compare, clone at that commit
140
278
  const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
141
279
  if (cmp_err) throw e('Compare failed', cmp_err)
142
280
 
@@ -145,17 +283,23 @@ const run = (args) => catch_errors('Diff failed', async () => {
145
283
  const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
146
284
 
147
285
  const classified = classify_changes(base_files, base_files, remote_files)
286
+ const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
287
+
288
+ if (!skip_content) {
289
+ const [enrich_err] = await enrich_with_diffs(report, base_clone.files, null, remote_clone.files)
290
+ if (enrich_err) throw e('Content diff failed', enrich_err)
291
+ }
148
292
 
149
293
  if (args.flags.json) {
150
- const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
151
294
  print_json({ data: { mode, report } })
152
295
  } else {
153
296
  print_file_table(classified)
297
+ print_report_diffs(report)
154
298
  }
155
299
  return
156
300
  }
157
301
 
158
- // Full three-way diff — get head commit via compare
302
+ // Full three-way diff
159
303
  const [cmp_err, cmp_data] = await repos_api.compare(owner, repo, lock_entry.base_commit)
160
304
  if (cmp_err) throw e('Compare failed', cmp_err)
161
305
 
@@ -169,10 +313,16 @@ const run = (args) => catch_errors('Diff failed', async () => {
169
313
  const classified = classify_changes(base_files, local_files, remote_files)
170
314
  const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
171
315
 
316
+ if (!skip_content) {
317
+ const [enrich_err] = await enrich_with_diffs(report, base_clone.files, skill_dir, remote_clone.files)
318
+ if (enrich_err) throw e('Content diff failed', enrich_err)
319
+ }
320
+
172
321
  if (args.flags.json) {
173
322
  print_json({ data: { mode, report } })
174
323
  } else {
175
324
  print_file_table(classified)
325
+ print_report_diffs(report)
176
326
  if (report.summary.conflicted > 0) {
177
327
  console.error('')
178
328
  print_warn(`${report.summary.conflicted} file(s) modified on both sides`)
@@ -23,21 +23,21 @@ Options:
23
23
  --tags <tags> Filter by tags (comma-separated)
24
24
  --type <type> Filter by type (skill, kit)
25
25
  --exact Use keyword-only matching instead of smart search
26
- --limit <n> Max results (default: 10, max: 50)
26
+ --limit <n> Max results (required, 1-50)
27
27
  --min-quality <n> Minimum quality score 0-100
28
28
  --json Output as JSON
29
29
 
30
30
  Aliases: s
31
31
 
32
32
  Examples:
33
- happyskills search deploy
34
- happyskills search "REST API with Node.js"
35
- happyskills search --mine
36
- happyskills search deploy --workspace acme
37
- happyskills search --type kit
38
- happyskills s --personal --json
33
+ happyskills search deploy --limit 10
34
+ happyskills search "REST API with Node.js" --limit 10
35
+ happyskills search --mine --limit 20
36
+ happyskills search deploy --workspace acme --limit 50
37
+ happyskills search --type kit --limit 10
38
+ happyskills s --personal --json --limit 20
39
39
  happyskills search "deploy to AWS" --limit 5
40
- happyskills search deploy --exact`
40
+ happyskills search deploy --exact --limit 10`
41
41
 
42
42
  const QUALITY_TIERS = [
43
43
  { min: 80, label: 'High quality', color: cyan },
@@ -95,7 +95,7 @@ const format_smart_result = (item, index) => {
95
95
  }
96
96
 
97
97
  const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
98
- const limit = args.flags.limit ? parseInt(args.flags.limit) : 10
98
+ const limit = parseInt(args.flags.limit)
99
99
  const capped_limit = Math.min(Math.max(limit, 1), 50)
100
100
  const min_quality = args.flags['min-quality'] != null ? parseInt(args.flags['min-quality']) : null
101
101
 
@@ -230,6 +230,10 @@ const run = (args) => catch_errors('Search failed', async () => {
230
230
  throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
231
231
  }
232
232
 
233
+ if (!args.flags.limit) {
234
+ throw new UsageError('--limit is required. Specify the number of results you want (e.g. --limit 10).')
235
+ }
236
+
233
237
  if (type && !VALID_SKILL_TYPES.includes(type)) {
234
238
  throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
235
239
  }
@@ -276,8 +276,9 @@ describe('CLI — --json: stdout is always valid JSON', () => {
276
276
  })
277
277
 
278
278
  it('network error produces JSON with code NETWORK_ERROR', () => {
279
- // search requires a network call; localhost:0 always fails
280
- const { stdout, code } = run(['search', 'anything', '--json'])
279
+ // search requires a network call; localhost:0 always fails.
280
+ // --limit is required, so it must be passed to reach the network call.
281
+ const { stdout, code } = run(['search', 'anything', '--json', '--limit', '10'])
281
282
  assert.strictEqual(code, 4)
282
283
  const out = parse_json_output(stdout, 'search --json network failure')
283
284
  assert.strictEqual(out.error.code, 'NETWORK_ERROR')
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Lightweight unified diff — no external dependencies.
3
+ *
4
+ * Uses the Myers diff algorithm for line-level comparison, then formats
5
+ * the result as a standard unified diff with configurable context lines.
6
+ *
7
+ * Designed for memory efficiency:
8
+ * - No intermediate copies of full file content
9
+ * - Hunks are built incrementally
10
+ * - Input strings are split once, not retained
11
+ */
12
+
13
+ // ─── Myers diff ──────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Compute the shortest edit script between two line arrays using Myers' O(ND) algorithm.
17
+ * Returns an array of { type: 'equal'|'delete'|'insert', old_idx?, new_idx? } operations.
18
+ *
19
+ * For files with small edit distance D, this runs in O((M+N)*D) time and O(D²) space
20
+ * for the trace. Skill files are typically < 1000 lines with small diffs, so this is
21
+ * well within acceptable bounds.
22
+ */
23
+ const myers_edit_script = (old_lines, new_lines) => {
24
+ const N = old_lines.length
25
+ const M = new_lines.length
26
+
27
+ if (N === 0 && M === 0) return []
28
+ if (N === 0) return new_lines.map((_, j) => ({ type: 'insert', new_idx: j }))
29
+ if (M === 0) return old_lines.map((_, i) => ({ type: 'delete', old_idx: i }))
30
+
31
+ const MAX = N + M
32
+ const V = new Map([[1, 0]])
33
+ const trace = []
34
+
35
+ for (let d = 0; d <= MAX; d++) {
36
+ // Snapshot V for backtracking
37
+ trace.push(new Map(V))
38
+
39
+ for (let k = -d; k <= d; k += 2) {
40
+ let x
41
+ if (k === -d || (k !== d && (V.get(k - 1) ?? -1) < (V.get(k + 1) ?? -1))) {
42
+ x = V.get(k + 1) ?? 0 // move down (insert)
43
+ } else {
44
+ x = (V.get(k - 1) ?? 0) + 1 // move right (delete)
45
+ }
46
+ let y = x - k
47
+
48
+ // Follow diagonal (equal lines)
49
+ while (x < N && y < M && old_lines[x] === new_lines[y]) {
50
+ x++
51
+ y++
52
+ }
53
+
54
+ V.set(k, x)
55
+
56
+ if (x >= N && y >= M) {
57
+ return backtrack(trace, old_lines, new_lines, d)
58
+ }
59
+ }
60
+ }
61
+
62
+ // Should never reach here
63
+ return []
64
+ }
65
+
66
+ /**
67
+ * Backtrack through the trace to reconstruct the edit script.
68
+ */
69
+ const backtrack = (trace, old_lines, new_lines, d) => {
70
+ const ops = []
71
+ let x = old_lines.length
72
+ let y = new_lines.length
73
+
74
+ for (let step = d; step > 0; step--) {
75
+ // trace[step] = V snapshot taken at the START of iteration d=step,
76
+ // which is V AFTER d=step-1 completed — i.e., the previous step's result
77
+ const v_prev = trace[step]
78
+ const k = x - y
79
+
80
+ let prev_k
81
+ if (k === -step || (k !== step && (v_prev.get(k - 1) ?? -1) < (v_prev.get(k + 1) ?? -1))) {
82
+ prev_k = k + 1 // came from above (insert)
83
+ } else {
84
+ prev_k = k - 1 // came from left (delete)
85
+ }
86
+
87
+ const prev_x = v_prev.get(prev_k) ?? 0
88
+ const prev_y = prev_x - prev_k
89
+
90
+ // Diagonal moves (equal lines) — collect in reverse
91
+ while (x > prev_x + (prev_k < k ? 1 : 0) && y > prev_y + (prev_k > k ? 1 : 0)) {
92
+ x--
93
+ y--
94
+ ops.push({ type: 'equal', old_idx: x, new_idx: y })
95
+ }
96
+
97
+ // The actual edit
98
+ if (prev_k < k) {
99
+ // Moved right → delete
100
+ x--
101
+ ops.push({ type: 'delete', old_idx: x })
102
+ } else {
103
+ // Moved down → insert
104
+ y--
105
+ ops.push({ type: 'insert', new_idx: y })
106
+ }
107
+ }
108
+
109
+ // Remaining diagonal at the start
110
+ while (x > 0 && y > 0) {
111
+ x--
112
+ y--
113
+ ops.push({ type: 'equal', old_idx: x, new_idx: y })
114
+ }
115
+
116
+ ops.reverse()
117
+ return ops
118
+ }
119
+
120
+ // ─── Hunk building ───────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Group edit operations into unified diff hunks with context lines.
124
+ */
125
+ const build_hunks = (ops, old_lines, new_lines, context) => {
126
+ // Find ranges of changes (non-equal ops)
127
+ const change_indices = []
128
+ for (let i = 0; i < ops.length; i++) {
129
+ if (ops[i].type !== 'equal') change_indices.push(i)
130
+ }
131
+
132
+ if (change_indices.length === 0) return []
133
+
134
+ // Group changes that are within (2 * context) lines of each other
135
+ const groups = []
136
+ let group_start = change_indices[0]
137
+ let group_end = change_indices[0]
138
+
139
+ for (let i = 1; i < change_indices.length; i++) {
140
+ const gap = change_indices[i] - change_indices[i - 1]
141
+ if (gap <= context * 2 + 1) {
142
+ group_end = change_indices[i]
143
+ } else {
144
+ groups.push([group_start, group_end])
145
+ group_start = change_indices[i]
146
+ group_end = change_indices[i]
147
+ }
148
+ }
149
+ groups.push([group_start, group_end])
150
+
151
+ // Build hunks from groups
152
+ const hunks = []
153
+ for (const [start, end] of groups) {
154
+ const hunk_start = Math.max(0, start - context)
155
+ const hunk_end = Math.min(ops.length - 1, end + context)
156
+
157
+ let old_start = null
158
+ let new_start = null
159
+ let old_count = 0
160
+ let new_count = 0
161
+ const lines = []
162
+
163
+ for (let i = hunk_start; i <= hunk_end; i++) {
164
+ const op = ops[i]
165
+ if (op.type === 'equal') {
166
+ if (old_start === null) old_start = op.old_idx
167
+ if (new_start === null) new_start = op.new_idx
168
+ lines.push(` ${old_lines[op.old_idx]}`)
169
+ old_count++
170
+ new_count++
171
+ } else if (op.type === 'delete') {
172
+ if (old_start === null) old_start = op.old_idx
173
+ if (new_start === null) {
174
+ // Find the new_idx from the nearest equal op before this
175
+ new_start = find_new_start(ops, i)
176
+ }
177
+ lines.push(`-${old_lines[op.old_idx]}`)
178
+ old_count++
179
+ } else if (op.type === 'insert') {
180
+ if (new_start === null) new_start = op.new_idx
181
+ if (old_start === null) {
182
+ old_start = find_old_start(ops, i)
183
+ }
184
+ lines.push(`+${new_lines[op.new_idx]}`)
185
+ new_count++
186
+ }
187
+ }
188
+
189
+ hunks.push({
190
+ old_start: (old_start ?? 0) + 1, // 1-based
191
+ old_count,
192
+ new_start: (new_start ?? 0) + 1, // 1-based
193
+ new_count,
194
+ lines
195
+ })
196
+ }
197
+
198
+ return hunks
199
+ }
200
+
201
+ const find_new_start = (ops, from) => {
202
+ for (let i = from - 1; i >= 0; i--) {
203
+ if (ops[i].new_idx !== undefined) return ops[i].new_idx + 1
204
+ }
205
+ return 0
206
+ }
207
+
208
+ const find_old_start = (ops, from) => {
209
+ for (let i = from - 1; i >= 0; i--) {
210
+ if (ops[i].old_idx !== undefined) return ops[i].old_idx + 1
211
+ }
212
+ return 0
213
+ }
214
+
215
+ // ─── Public API ──────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Compute a unified diff between two text strings.
219
+ *
220
+ * @param {string} old_text - Original text
221
+ * @param {string} new_text - Modified text
222
+ * @param {string} old_label - Label for original (e.g., "base/SKILL.md")
223
+ * @param {string} new_label - Label for modified (e.g., "local/SKILL.md")
224
+ * @param {number} [context=3] - Number of context lines around each change
225
+ * @returns {string} Unified diff string, empty if no differences
226
+ */
227
+ const unified_diff = (old_text, new_text, old_label, new_label, context = 3) => {
228
+ if (old_text === new_text) return ''
229
+
230
+ const old_lines = old_text.split('\n')
231
+ const new_lines = new_text.split('\n')
232
+
233
+ const ops = myers_edit_script(old_lines, new_lines)
234
+ const hunks = build_hunks(ops, old_lines, new_lines, context)
235
+
236
+ if (hunks.length === 0) return ''
237
+
238
+ const header = `--- ${old_label}\n+++ ${new_label}`
239
+ const hunk_strings = hunks.map(h => {
240
+ const range = `@@ -${h.old_start},${h.old_count} +${h.new_start},${h.new_count} @@`
241
+ return `${range}\n${h.lines.join('\n')}`
242
+ })
243
+
244
+ return `${header}\n${hunk_strings.join('\n')}`
245
+ }
246
+
247
+ module.exports = { unified_diff }