happyskills 0.44.0 → 0.44.1

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.44.1] - 2026-05-13
11
+
12
+ ### Changed
13
+ - `diff` no longer hard-blocks when the target skill has lock-vs-disk drift. Previously, running `happyskills diff <skill>` against a drifted skill threw a `UsageError` telling the user to run `happyskills install <skill> --fresh` before diffing — but `--fresh` overwrites the on-disk content, destroying the very local state the user was trying to inspect. Drift is exactly the case where diff is most useful as a diagnostic tool. The command now prints a one-line warning (`<skill> has drift: lock <X>, disk <Y>. Diff is shown against the lock-recorded base (<X>).`) and proceeds with the diff. JSON output gains a top-level `drift` field (same `{ reason, lock_version, disk_version }` shape used elsewhere) in `local` and `full` modes, or `null` when clean. `--remote` mode skips the drift probe entirely since it reads nothing from disk. Other drift-aware commands are unchanged — `status`/`check`/`list` still surface drift in their reports, `update` still skips drifted skills to avoid clobbering local work, and the post-write self-checks in `install`/`pull`/`bump`/`publish`/`convert` still throw on inconsistency.
14
+
10
15
  ## [0.44.0] - 2026-05-12
11
16
 
12
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
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)",
@@ -249,16 +249,20 @@ const run = (args) => catch_errors('Diff failed', async () => {
249
249
  const base_dir = skills_dir(is_global, project_root)
250
250
  const skill_dir = skill_install_dir(base_dir, repo)
251
251
 
252
- // A drifted skill makes diff incoherent: the lock claims one version is
253
- // installed, the disk has a different version, and `base_commit` points at
254
- // the lock-claimed version. Diffing local-vs-base would show the entire
255
- // disk content as "modified" noise, not signal. Refuse with a clear
256
- // remediation rather than show a misleading report.
257
- const [, drift] = await verify_lock_disk_consistency(lock_entry, skill_dir)
258
- if (drift && !drift.ok) {
259
- throw new UsageError(
252
+ // Drift (lock-vs-disk version mismatch) does NOT block diff diff is the
253
+ // diagnostic tool a user reaches for precisely when things have diverged,
254
+ // and the obvious "remediation" (install --fresh) would destroy the local
255
+ // content they're trying to inspect. We probe drift only to warn the user
256
+ // that the "base" shown is the lock-recorded base (not the disk version's
257
+ // base). Skipped in --remote mode, which reads nothing from disk.
258
+ const drift = mode === 'remote'
259
+ ? null
260
+ : (await verify_lock_disk_consistency(lock_entry, skill_dir))[1]
261
+ const has_drift = drift && !drift.ok
262
+ if (has_drift && !args.flags.json) {
263
+ print_warn(
260
264
  `${skill_name} has drift: lock ${drift.expected}, disk ${drift.actual || 'none'}. ` +
261
- `Run ${code(`happyskills install ${skill_name} --fresh`)} to restore the install record before diffing.`
265
+ `Diff is shown against the lock-recorded base (${lock_entry.version}).`
262
266
  )
263
267
  }
264
268
 
@@ -280,7 +284,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
280
284
  }
281
285
 
282
286
  if (args.flags.json) {
283
- print_json({ data: { mode, report } })
287
+ print_json({ data: { mode, report, drift: has_drift ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } : null } })
284
288
  } else {
285
289
  print_file_table(classified)
286
290
  print_report_diffs(report)
@@ -333,7 +337,7 @@ const run = (args) => catch_errors('Diff failed', async () => {
333
337
  }
334
338
 
335
339
  if (args.flags.json) {
336
- print_json({ data: { mode, report } })
340
+ print_json({ data: { mode, report, drift: has_drift ? { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual } : null } })
337
341
  } else {
338
342
  print_file_table(classified)
339
343
  print_report_diffs(report)
@@ -11,6 +11,11 @@
11
11
  * - `list` reported the lock's version as the installed version (lying)
12
12
  * - `diff` used the lock's `base_commit` as a comparison baseline that no
13
13
  * longer matched what was on disk (incoherent diff)
14
+ *
15
+ * Note on `diff`: an earlier fix hard-blocked `diff` on drift. That was wrong
16
+ * — diff is the diagnostic tool a user reaches for *because* of drift, and
17
+ * the suggested "install --fresh" remediation would destroy the local
18
+ * content. The current behavior is to warn and proceed.
14
19
  * - `update` could decide a drifted skill was up-to-date and skip it
15
20
  *
16
21
  * These tests reconstruct the exact linwong scenario that surfaced the bug
@@ -258,19 +263,38 @@ describe('list — drift surfacing', () => {
258
263
 
259
264
  // ─── diff ─────────────────────────────────────────────────────────────────────
260
265
 
261
- describe('diff — drift refusal', () => {
262
- it('diff refuses with a clear UsageError when the skill is drifted', () => {
263
- // diff against an inconsistent baseline produces noise, not signal —
264
- // refusing is the principal-friendly choice.
266
+ describe('diff — drift does not block', () => {
267
+ it('diff proceeds past the drift check (no UsageError) and warns the user', () => {
268
+ // Drift must not block diff: diff is the diagnostic tool for this case,
269
+ // and "install --fresh" would destroy the local content. We assert that
270
+ // the command moves past the drift gate — it will then fail trying to
271
+ // reach the fake API URL, but exit code 2 (UsageError) means the old
272
+ // pre-block is back.
265
273
  const { root, cleanup } = scaffold_drifted([
266
274
  { full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
267
275
  ])
268
276
  try {
269
277
  const { code, stderr } = run(['diff', 'acme/cant-diff'], { cwd: root })
270
- assert.strictEqual(code, 2, 'should exit with usage error code')
271
- assert.ok(stderr.toLowerCase().includes('drift'), 'error must mention drift')
272
- assert.ok(stderr.includes('0.4.0'), 'should mention lock version')
273
- assert.ok(stderr.includes('0.3.0'), 'should mention disk version')
278
+ assert.notStrictEqual(code, 2, 'must not exit with the old drift UsageError')
279
+ assert.ok(
280
+ stderr.toLowerCase().includes('drift') && stderr.includes('0.4.0') && stderr.includes('0.3.0'),
281
+ 'should warn about drift with both versions'
282
+ )
283
+ assert.ok(
284
+ !/before diffing/i.test(stderr),
285
+ 'must not tell the user to fix drift before diffing'
286
+ )
287
+ } finally { cleanup() }
288
+ })
289
+
290
+ it('diff --remote skips the drift probe entirely (drift is irrelevant when no disk is read)', () => {
291
+ const { root, cleanup } = scaffold_drifted([
292
+ { full: 'acme/cant-diff', short: 'cant-diff', lock_version: '0.4.0', disk_version: '0.3.0' }
293
+ ])
294
+ try {
295
+ const { code, stderr } = run(['diff', 'acme/cant-diff', '--remote'], { cwd: root })
296
+ assert.notStrictEqual(code, 2, 'must not exit with the old drift UsageError')
297
+ assert.ok(!/drift/i.test(stderr), '--remote mode should not surface drift at all')
274
298
  } finally { cleanup() }
275
299
  })
276
300
  })