happyskills 0.21.0 → 0.22.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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.22.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - Add remote version check to `bump` — warns if the target version already exists on the registry before proceeding
14
+ - Add per-file merge strategy to `pull` — use `--theirs SKILL.md,skill.json --ours references/foo.md` to resolve conflicts file-by-file instead of all-or-nothing
15
+ - Add `--strict` flag to `pull` — fails on incompatible dependency ranges instead of warning
16
+ - Add dependency range validation to `pull` — warns when an installed dependency version falls outside the constraint declared in the merged `skill.json`
17
+
18
+ ### Changed
19
+ - Move auth verification earlier in `publish` — `list_workspaces()` now runs immediately after `require_token()`, failing fast on invalid/expired tokens before validation or upload
20
+ - Cache `hash_directory` results in memory within a single command invocation — prevents redundant disk reads when `status` or `refresh` checks multiple skills
21
+
10
22
  ## [0.21.0] - 2026-03-30
11
23
 
12
24
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.21.0",
3
+ "version": "0.22.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)",
@@ -8,7 +8,8 @@ const { find_project_root } = require('../config/paths')
8
8
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
9
9
  const { write_lock, update_lock_skills } = require('../lock/writer')
10
10
  const { hash_directory } = require('../lock/integrity')
11
- const { print_help, print_success, print_json } = require('../ui/output')
11
+ const repos_api = require('../api/repos')
12
+ const { print_help, print_success, print_warn, print_json } = require('../ui/output')
12
13
  const { exit_with_error, UsageError } = require('../utils/errors')
13
14
  const { EXIT_CODES } = require('../constants')
14
15
 
@@ -50,6 +51,17 @@ const run = (args) => catch_errors('Bump failed', async () => {
50
51
 
51
52
  const old_version = manifest.version
52
53
 
54
+ // Read lock file early to resolve full skill name for remote check + lock update
55
+ const project_root = find_project_root()
56
+ const [lock_err, lock_data] = await read_lock(project_root)
57
+ let lock_key = null
58
+ let all_skills = null
59
+ if (!lock_err && lock_data) {
60
+ all_skills = get_all_locked_skills(lock_data)
61
+ const suffix = `/${skill_name}`
62
+ lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix)) || null
63
+ }
64
+
53
65
  if (BUMP_TYPES.includes(input)) {
54
66
  const new_version = inc(old_version, input)
55
67
  if (!new_version) throw new Error(`Failed to bump version from ${old_version}`)
@@ -60,6 +72,15 @@ const run = (args) => catch_errors('Bump failed', async () => {
60
72
  manifest.version = cleaned
61
73
  }
62
74
 
75
+ // Warn if target version already exists remotely
76
+ if (lock_key) {
77
+ const [, batch_data] = await repos_api.check_updates([lock_key])
78
+ const info = batch_data?.results?.[lock_key]
79
+ if (info?.latest_version === manifest.version) {
80
+ print_warn(`Version ${manifest.version} already exists remotely for ${lock_key}. Publishing will fail with VERSION_EXISTS.`)
81
+ }
82
+ }
83
+
63
84
  const validation = validate_manifest(manifest)
64
85
  if (!validation.valid) {
65
86
  throw new Error(`Invalid manifest after bump: ${validation.errors.join(', ')}`)
@@ -68,23 +89,16 @@ const run = (args) => catch_errors('Bump failed', async () => {
68
89
  const [write_err] = await write_manifest(dir, manifest)
69
90
  if (write_err) throw e('Failed to update skill.json', write_err)
70
91
 
71
- const project_root = find_project_root()
72
- const [lock_err, lock_data] = await read_lock(project_root)
73
- if (!lock_err && lock_data) {
74
- const all_skills = get_all_locked_skills(lock_data)
75
- const suffix = `/${skill_name}`
76
- const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
77
- if (lock_key && all_skills[lock_key]) {
78
- const [hash_err, integrity] = await hash_directory(dir)
79
- const updated_entry = {
80
- ...all_skills[lock_key],
81
- version: manifest.version,
82
- ref: `refs/tags/v${manifest.version}`
83
- }
84
- if (!hash_err && integrity) updated_entry.integrity = integrity
85
- const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
86
- await write_lock(project_root, updated_skills)
92
+ if (lock_key && all_skills?.[lock_key]) {
93
+ const [hash_err, integrity] = await hash_directory(dir)
94
+ const updated_entry = {
95
+ ...all_skills[lock_key],
96
+ version: manifest.version,
97
+ ref: `refs/tags/v${manifest.version}`
87
98
  }
99
+ if (!hash_err && integrity) updated_entry.integrity = integrity
100
+ const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
101
+ await write_lock(project_root, updated_skills)
88
102
  }
89
103
 
90
104
  if (args.flags.json) {
@@ -67,6 +67,10 @@ const run = (args) => catch_errors('Publish failed', async () => {
67
67
 
68
68
  await require_token()
69
69
 
70
+ // Verify auth early — fail fast before validation and upload
71
+ const [ws_err, workspaces] = await workspaces_api.list_workspaces()
72
+ if (ws_err) throw e('Authentication failed — run \'happyskills login\' to re-authenticate.', ws_err)
73
+
70
74
  const [dir_err, dir] = await resolve_skill_dir(skill_name)
71
75
  if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
72
76
 
@@ -133,9 +137,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
133
137
  if (resolved_owner) owner = resolved_owner
134
138
  }
135
139
 
136
- const [ws_err, workspaces] = await workspaces_api.list_workspaces()
137
- if (ws_err) { spinner.fail('Failed to list workspaces'); throw e('Failed to list workspaces', ws_err) }
138
-
139
140
  const workspace = choose_workspace(workspaces, owner)
140
141
 
141
142
  if (manifest.dependencies && Object.keys(manifest.dependencies).length > 0) {
@@ -10,6 +10,7 @@ const { three_way_merge } = require('../merge/text_merge')
10
10
  const { merge_skill_json } = require('../merge/json_merge')
11
11
  const { merge_changelog } = require('../merge/changelog_merge')
12
12
  const { hash_blob } = require('../utils/git_hash')
13
+ const { satisfies } = require('../utils/semver')
13
14
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
14
15
  const { write_lock, update_lock_skills } = require('../lock/writer')
15
16
  const { hash_directory } = require('../lock/integrity')
@@ -28,16 +29,18 @@ Arguments:
28
29
  owner/skill Skill to pull (required)
29
30
 
30
31
  Options:
31
- --theirs Take remote version on conflicts
32
- --ours Keep local version on conflicts
32
+ --theirs [files] Take remote version on conflicts (all, or comma-separated file list)
33
+ --ours [files] Keep local version on conflicts (all, or comma-separated file list)
33
34
  --force Discard all local changes, take remote entirely
34
35
  -g, --global Pull globally installed skill
36
+ --strict Fail on incompatible dependency ranges instead of warning
35
37
  --json Output as JSON
36
38
  --full-report Include inline file content and resolution steps in JSON output (for AI agent review)
37
39
 
38
40
  Examples:
39
41
  happyskills pull acme/deploy-aws
40
42
  happyskills pull acme/deploy-aws --theirs
43
+ happyskills pull acme/deploy-aws --theirs SKILL.md,skill.json --ours references/foo.md
41
44
  happyskills pull acme/deploy-aws --json --full-report`
42
45
 
43
46
  // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -105,8 +108,32 @@ const run = (args) => catch_errors('Pull failed', async () => {
105
108
  }
106
109
 
107
110
  const is_global = args.flags.global || false
108
- const strategy = args.flags.theirs ? 'theirs' : args.flags.ours ? 'ours' : args.flags.force ? 'force' : null
109
111
  const full_report = !!(args.flags.json && args.flags['full-report'])
112
+
113
+ // Parse per-file strategies: --theirs SKILL.md,skill.json --ours references/foo.md
114
+ const theirs_files = typeof args.flags.theirs === 'string'
115
+ ? new Set(args.flags.theirs.split(',').map(s => s.trim()))
116
+ : null
117
+ const ours_files = typeof args.flags.ours === 'string'
118
+ ? new Set(args.flags.ours.split(',').map(s => s.trim()))
119
+ : null
120
+
121
+ // Validate no path appears in both --theirs and --ours
122
+ if (theirs_files && ours_files) {
123
+ for (const p of theirs_files) {
124
+ if (ours_files.has(p)) throw new UsageError(`"${p}" cannot be in both --theirs and --ours.`)
125
+ }
126
+ }
127
+
128
+ // Global strategy (boolean flags only, backward compatible)
129
+ const strategy = args.flags.theirs === true ? 'theirs' : args.flags.ours === true ? 'ours' : args.flags.force ? 'force' : null
130
+
131
+ // Per-file strategy resolver: per-file overrides take precedence over global
132
+ const file_strategy = (file_path) => {
133
+ if (theirs_files?.has(file_path)) return 'theirs'
134
+ if (ours_files?.has(file_path)) return 'ours'
135
+ return strategy
136
+ }
110
137
  const project_root = find_project_root()
111
138
 
112
139
  // 1. Read lock file
@@ -212,17 +239,33 @@ const run = (args) => catch_errors('Pull failed', async () => {
212
239
  const classified = classify_changes(base_files, local_files, remote_files)
213
240
  const report = build_report(skill_name, lock_entry.version, cmp_data.head_version, classified)
214
241
 
242
+ // Validate per-file strategy paths against actual conflicts
243
+ if (theirs_files || ours_files) {
244
+ const both_paths = new Set(classified.both_modified.map(e => e.path))
245
+ for (const p of (theirs_files || [])) {
246
+ if (!both_paths.has(p)) print_warn(`--theirs path "${p}" not found in conflicted files — ignored.`)
247
+ }
248
+ for (const p of (ours_files || [])) {
249
+ if (!both_paths.has(p)) print_warn(`--ours path "${p}" not found in conflicted files — ignored.`)
250
+ }
251
+ }
252
+
253
+ // Split both_modified by resolved strategy
254
+ const auto_merge_entries = classified.both_modified.filter(e => !file_strategy(e.path))
255
+ const theirs_entries = classified.both_modified.filter(e => file_strategy(e.path) === 'theirs')
256
+ const ours_entries = classified.both_modified.filter(e => file_strategy(e.path) === 'ours')
257
+
215
258
  // Apply changes
216
259
  spinner.update('Applying changes...')
217
260
  const base_file_map = new Map((base_clone.files || []).map(f => [f.path, f]))
218
261
  const remote_file_map = new Map((remote_clone.files || []).map(f => [f.path, f]))
219
262
 
220
- // Auto-merge both_modified files when no strategy is set
263
+ // Auto-merge both_modified files that have no explicit strategy
221
264
  const conflict_files = []
222
265
  const json_conflicts = []
223
- if (classified.both_modified.length > 0 && !strategy) {
266
+ if (auto_merge_entries.length > 0) {
224
267
  spinner.update('Auto-merging...')
225
- for (const entry of classified.both_modified) {
268
+ for (const entry of auto_merge_entries) {
226
269
  const report_file = report.files.find(f => f.path === entry.path)
227
270
 
228
271
  // Delete-vs-modify — cannot auto-merge, keep what exists
@@ -333,9 +376,9 @@ const run = (args) => catch_errors('Pull failed', async () => {
333
376
  }
334
377
  }
335
378
 
336
- // Both-modified → apply strategy
337
- if (strategy === 'theirs') {
338
- for (const entry of classified.both_modified) {
379
+ // Both-modified → apply explicit strategy (global or per-file)
380
+ if (theirs_entries.length > 0) {
381
+ for (const entry of theirs_entries) {
339
382
  if (full_report) {
340
383
  const report_file = report.files.find(f => f.path === entry.path)
341
384
  if (report_file) {
@@ -362,8 +405,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
362
405
  }
363
406
  }
364
407
  }
365
- if (strategy === 'ours' && full_report) {
366
- for (const entry of classified.both_modified) {
408
+ if (ours_entries.length > 0 && full_report) {
409
+ for (const entry of ours_entries) {
367
410
  const report_file = report.files.find(f => f.path === entry.path)
368
411
  if (report_file) {
369
412
  const content = { base: decode_b64(base_file_map.get(entry.path)), remote: decode_b64(remote_file_map.get(entry.path)) }
@@ -407,8 +450,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
407
450
  if (report.summary.auto_merged > 0) {
408
451
  print_info(`${report.summary.auto_merged} file(s) auto-applied (non-conflicting changes)`)
409
452
  }
410
- if (classified.both_modified.length > 0 && strategy) {
411
- print_info(`${classified.both_modified.length} conflict(s) resolved with --${strategy}`)
453
+ const resolved_count = theirs_entries.length + ours_entries.length
454
+ if (resolved_count > 0) {
455
+ const label = strategy ? `--${strategy}` : 'per-file strategy'
456
+ print_info(`${resolved_count} conflict(s) resolved with ${label}`)
412
457
  }
413
458
  const auto_merged_count = classified.both_modified.length - conflict_files.length
414
459
  if (auto_merged_count > 0 && !strategy) {
@@ -457,7 +502,12 @@ const reconcile_dependencies = (skill_dir, skill_name, lock_data, is_global, pro
457
502
  to_install.push(dep)
458
503
  continue
459
504
  }
460
- // Installed version exists — skip (higher-version-wins is handled by the resolver)
505
+ // Check if installed version satisfies the new constraint
506
+ if (constraint && installed.version && !satisfies(installed.version, constraint)) {
507
+ const msg = `${dep}@${installed.version} is installed but ${skill_name} requires ${constraint}. Installed version is outside the declared range — verify compatibility.`
508
+ if (args.flags.strict) throw new Error(msg)
509
+ print_warn(msg)
510
+ }
461
511
  }
462
512
 
463
513
  if (to_install.length === 0) return
@@ -8,7 +8,13 @@ const hash_file = (file_path) => catch_errors(`Failed to hash file ${file_path}`
8
8
  return crypto.createHash('sha256').update(content).digest('hex')
9
9
  })
10
10
 
11
+ const _hash_cache = new Map()
12
+
13
+ const clear_integrity_cache = () => _hash_cache.clear()
14
+
11
15
  const hash_directory = (dir_path) => catch_errors(`Failed to hash directory ${dir_path}`, async () => {
16
+ if (_hash_cache.has(dir_path)) return _hash_cache.get(dir_path)
17
+
12
18
  const hash = crypto.createHash('sha256')
13
19
  const entries = await collect_files(dir_path)
14
20
 
@@ -20,7 +26,9 @@ const hash_directory = (dir_path) => catch_errors(`Failed to hash directory ${di
20
26
  hash.update(content)
21
27
  }
22
28
 
23
- return `sha256-${hash.digest('hex')}`
29
+ const result = `sha256-${hash.digest('hex')}`
30
+ _hash_cache.set(dir_path, result)
31
+ return result
24
32
  })
25
33
 
26
34
  const collect_files = async (dir_path, base_path = dir_path) => {
@@ -51,4 +59,4 @@ const verify_integrity = (dir_path, expected_hash) => catch_errors('Integrity ve
51
59
  return actual_hash === expected_hash
52
60
  })
53
61
 
54
- module.exports = { hash_file, hash_directory, verify_integrity }
62
+ module.exports = { hash_file, hash_directory, verify_integrity, clear_integrity_cache }
@@ -4,7 +4,7 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
6
  const { detect_status } = require('./detector')
7
- const { hash_directory } = require('../lock/integrity')
7
+ const { hash_directory, clear_integrity_cache } = require('../lock/integrity')
8
8
 
9
9
  const make_tmp = () => {
10
10
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-test-'))
@@ -39,8 +39,9 @@ describe('detect_status', () => {
39
39
 
40
40
  const [, integrity] = await hash_directory(tmp_dir)
41
41
 
42
- // Modify the file
42
+ // Modify the file and clear hash cache (cache is per-command-invocation optimization)
43
43
  fs.writeFileSync(path.join(tmp_dir, 'SKILL.md'), 'modified')
44
+ clear_integrity_cache()
44
45
 
45
46
  const lock_entry = { base_integrity: integrity, base_commit: 'abc123' }
46
47
  const [err, result] = await detect_status(lock_entry, tmp_dir)