happyskills 0.16.0 → 0.18.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,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-03-29
11
+
12
+ ### Added
13
+ - Add `pull` command for pulling remote changes and merging with local files — supports `--theirs`, `--ours`, and `--force` strategies with three-way file classification and dependency reconciliation
14
+ - Add `diff` command (`d` alias) for file-level difference display — supports `--remote` (base vs remote), `--full` (three-way), and default local vs base modes
15
+ - Add `cli/src/merge/comparator.js` module with `classify_changes()` for three-way file classification into 8 categories
16
+ - Add `cli/src/merge/report.js` module with `build_report()` for structured JSON merge reports (v1 contract)
17
+ - Add `compare()` and `get_blob()` to CLI API client for server-side tree comparison and individual blob downloads
18
+ - Extend `clone()` API client to accept `options.commit` for cloning at a specific commit SHA
19
+
20
+ ## [0.17.0] - 2026-03-29
21
+
22
+ ### Added
23
+ - Add `status` command (`st` alias) for divergence detection — shows whether installed skills have local modifications, remote updates, or both
24
+ - Add `--force` flag to `publish` to bypass divergence check when deliberately overwriting remote changes
25
+ - Add `--force` flag to `update` to overwrite skills with local modifications
26
+ - Add `base_commit` and `base_integrity` fields to lock file entries — tracks the install-time commit SHA and integrity hash for divergence detection
27
+ - Add `cli/src/merge/detector.js` module with `detect_status()` for local modification detection via integrity comparison
28
+ - Add `cli/src/utils/git_hash.js` with Git-format blob hashing (`sha256("blob <size>\0" + content)`)
29
+
30
+ ### Changed
31
+ - Bump lock file version from 1 to 2 (new `base_commit`/`base_integrity` fields)
32
+ - Switch file hashing in `file_collector.js` from raw SHA-256 to Git blob format (`hash_blob`) for compatibility with the registry's Git object format
33
+ - Send `base_commit` and `force` fields with push requests for server-side divergence checking
34
+ - Update `publish` to read `base_commit` from lock file, handle `409 DIVERGED` responses, and update `base_commit`/`base_integrity` in lock on success
35
+ - Update `update` to refuse overwriting skills with local modifications unless `--force` is passed
36
+
10
37
  ## [0.16.0] - 2026-03-28
11
38
 
12
39
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.16.0",
3
+ "version": "0.18.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/push.js CHANGED
@@ -12,13 +12,13 @@ const estimate_payload_size = (files) => {
12
12
  return size
13
13
  }
14
14
 
15
- const smart_push = (owner, repo, { version, message, files, visibility }, on_progress) =>
15
+ const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force }, on_progress) =>
16
16
  catch_errors('Smart push failed', async () => {
17
17
  const payload_size = estimate_payload_size(files)
18
18
 
19
19
  if (payload_size < DIRECT_PUSH_THRESHOLD) {
20
20
  // Small payload — use direct push
21
- const [err, data] = await repos_api.push(owner, repo, { version, message, files, visibility })
21
+ const [err, data] = await repos_api.push(owner, repo, { version, message, files, visibility, base_commit, force })
22
22
  if (err) throw e('Direct push failed', err)
23
23
  return data
24
24
  }
@@ -28,7 +28,7 @@ const smart_push = (owner, repo, { version, message, files, visibility }, on_pro
28
28
 
29
29
  // Step 1: Initiate
30
30
  const [init_err, init_data] = await initiate_upload(owner, repo, {
31
- version, message, files: file_meta, visibility
31
+ version, message, files: file_meta, visibility, base_commit, force
32
32
  })
33
33
  if (init_err) throw e('Upload initiation failed', init_err)
34
34
 
@@ -53,7 +53,7 @@ const smart_push = (owner, repo, { version, message, files, visibility }, on_pro
53
53
 
54
54
  // Step 3: Complete
55
55
  const [complete_err, complete_data] = await complete_upload(owner, repo, {
56
- upload_id, version, message, files: file_meta
56
+ upload_id, version, message, files: file_meta, base_commit, force
57
57
  })
58
58
  if (complete_err) throw e('Upload completion failed', complete_err)
59
59
 
package/src/api/repos.js CHANGED
@@ -22,9 +22,12 @@ const resolve_dependencies = (skill, version, installed = {}) => catch_errors('D
22
22
  return data
23
23
  })
24
24
 
25
- const clone = (owner, repo, ref) => catch_errors(`Clone ${owner}/${repo} failed`, async () => {
26
- const params = ref ? `?ref=${encodeURIComponent(ref)}` : ''
27
- const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${params}`)
25
+ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/${repo} failed`, async () => {
26
+ const params = new URLSearchParams()
27
+ if (options.commit) params.set('commit', options.commit)
28
+ else if (ref) params.set('ref', ref)
29
+ const qs = params.toString() ? `?${params}` : ''
30
+ const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
28
31
  if (errors) throw errors[errors.length - 1]
29
32
  return data
30
33
  })
@@ -65,4 +68,16 @@ const patch_repo = (owner, name, fields) => catch_errors(`Failed to update ${own
65
68
  return data
66
69
  })
67
70
 
68
- module.exports = { search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo }
71
+ const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${repo} failed`, async () => {
72
+ const [errors, data] = await client.post(`/repos/${owner}/${repo}/compare`, { base_commit })
73
+ if (errors) throw errors[errors.length - 1]
74
+ return data
75
+ })
76
+
77
+ const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/${sha} failed`, async () => {
78
+ const [errors, data] = await client.get(`/repos/${owner}/${repo}/blob/${sha}`)
79
+ if (errors) throw errors[errors.length - 1]
80
+ return data
81
+ })
82
+
83
+ module.exports = { search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob }
@@ -0,0 +1,178 @@
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 { detect_status } = require('../merge/detector')
6
+ const { classify_changes } = require('../merge/comparator')
7
+ const { build_report } = require('../merge/report')
8
+ const { hash_blob } = require('../utils/git_hash')
9
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
10
+ const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
11
+ const { print_help, print_info, print_json, print_warn, code } = require('../ui/output')
12
+ const { exit_with_error, UsageError } = require('../utils/errors')
13
+ const { EXIT_CODES } = require('../constants')
14
+
15
+ const HELP_TEXT = `Usage: happyskills diff <owner/skill> [options]
16
+
17
+ Show file-level differences for a skill.
18
+
19
+ Arguments:
20
+ owner/skill Skill to diff (required)
21
+
22
+ Options:
23
+ --remote Show base vs remote changes (default: local vs base)
24
+ --full Show three-way diff (base vs local vs remote)
25
+ -g, --global Diff globally installed skill
26
+ --json Output as JSON
27
+
28
+ Aliases: d
29
+
30
+ Examples:
31
+ happyskills diff acme/deploy-aws
32
+ happyskills diff acme/deploy-aws --remote
33
+ happyskills diff acme/deploy-aws --full`
34
+
35
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
36
+
37
+ const build_local_entries = (skill_dir) => catch_errors('Failed to build local entries', async () => {
38
+ const entries = []
39
+ const walk = async (dir, prefix) => {
40
+ const items = await fs.promises.readdir(dir, { withFileTypes: true })
41
+ for (const item of items) {
42
+ if (item.name.startsWith('.')) continue
43
+ const rel = prefix ? `${prefix}/${item.name}` : item.name
44
+ const full = path.join(dir, item.name)
45
+ if (item.isDirectory()) {
46
+ await walk(full, rel)
47
+ } else {
48
+ const content = await fs.promises.readFile(full)
49
+ entries.push({ path: rel, sha: hash_blob(content) })
50
+ }
51
+ }
52
+ }
53
+ await walk(skill_dir, '')
54
+ return entries
55
+ })
56
+
57
+ const STATUS_LABELS = {
58
+ remote_only_modified: 'M (remote)',
59
+ local_only_modified: 'M (local)',
60
+ both_modified: 'M (both)',
61
+ remote_only_added: 'A (remote)',
62
+ local_only_added: 'A (local)',
63
+ remote_only_deleted: 'D (remote)',
64
+ local_only_deleted: 'D (local)'
65
+ }
66
+
67
+ const print_file_table = (classified) => {
68
+ const lines = []
69
+ for (const [category, label] of Object.entries(STATUS_LABELS)) {
70
+ for (const entry of (classified[category] || [])) {
71
+ lines.push({ status: label, path: entry.path })
72
+ }
73
+ }
74
+
75
+ if (lines.length === 0) {
76
+ print_info('No differences found.')
77
+ return
78
+ }
79
+
80
+ const max_status = Math.max(...lines.map(l => l.status.length))
81
+ for (const line of lines) {
82
+ const padded = line.status + ' '.repeat(max_status - line.status.length)
83
+ console.log(` ${padded} ${line.path}`)
84
+ }
85
+ }
86
+
87
+ // ─── Main ─────────────────────────────────────────────────────────────────────
88
+
89
+ const run = (args) => catch_errors('Diff failed', async () => {
90
+ if (args.flags._show_help) {
91
+ print_help(HELP_TEXT)
92
+ return process.exit(EXIT_CODES.SUCCESS)
93
+ }
94
+
95
+ const skill_name = args._[0]
96
+ if (!skill_name || !skill_name.includes('/')) {
97
+ throw new UsageError('Skill name required in owner/name format. Example: happyskills diff acme/deploy-aws')
98
+ }
99
+
100
+ const is_global = args.flags.global || false
101
+ const mode = args.flags.full ? 'full' : args.flags.remote ? 'remote' : 'local'
102
+ const project_root = find_project_root()
103
+
104
+ // Read lock
105
+ const [lock_err, lock_data] = await read_lock(lock_root(is_global, project_root))
106
+ if (lock_err || !lock_data) throw new UsageError('No lock file found.')
107
+
108
+ const all_skills = get_all_locked_skills(lock_data)
109
+ const lock_entry = all_skills[skill_name]
110
+ if (!lock_entry) throw new UsageError(`${skill_name} is not installed.`)
111
+ if (!lock_entry.base_commit) throw new UsageError(`${skill_name} has no base_commit. Run ${code('happyskills install --fresh')} to upgrade the lock file.`)
112
+
113
+ const [owner, repo] = skill_name.split('/')
114
+ const base_dir = skills_dir(is_global, project_root)
115
+ const skill_dir = skill_install_dir(base_dir, repo)
116
+
117
+ // Always need base files
118
+ const [base_err, base_clone] = await repos_api.clone(owner, repo, null, { commit: lock_entry.base_commit })
119
+ if (base_err) throw e('Failed to fetch base files', base_err)
120
+ const base_files = (base_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
121
+
122
+ if (mode === 'local') {
123
+ // Local vs base — use classify_changes with base as the "remote" side
124
+ const [local_err, local_files] = await build_local_entries(skill_dir)
125
+ if (local_err) throw e('Failed to read local files', local_err)
126
+
127
+ const classified = classify_changes(base_files, local_files, base_files)
128
+
129
+ if (args.flags.json) {
130
+ const report = build_report(skill_name, lock_entry.version, lock_entry.version, classified)
131
+ print_json({ data: { mode, report } })
132
+ } else {
133
+ print_file_table(classified)
134
+ }
135
+ return
136
+ }
137
+
138
+ if (mode === 'remote') {
139
+ // Base vs remote — use classify_changes with base as the "local" side
140
+ const [remote_err, remote_clone] = await repos_api.clone(owner, repo, null)
141
+ if (remote_err) throw e('Failed to fetch remote files', remote_err)
142
+ const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
143
+
144
+ const classified = classify_changes(base_files, base_files, remote_files)
145
+
146
+ if (args.flags.json) {
147
+ const report = build_report(skill_name, lock_entry.version, null, classified)
148
+ print_json({ data: { mode, report } })
149
+ } else {
150
+ print_file_table(classified)
151
+ }
152
+ return
153
+ }
154
+
155
+ // Full three-way diff
156
+ const [local_err, local_files] = await build_local_entries(skill_dir)
157
+ if (local_err) throw e('Failed to read local files', local_err)
158
+
159
+ const [remote_err, remote_clone] = await repos_api.clone(owner, repo, null)
160
+ if (remote_err) throw e('Failed to fetch remote files', remote_err)
161
+ const remote_files = (remote_clone.files || []).map(f => ({ path: f.path, sha: f.sha }))
162
+
163
+ const classified = classify_changes(base_files, local_files, remote_files)
164
+ const report = build_report(skill_name, lock_entry.version, null, classified)
165
+
166
+ if (args.flags.json) {
167
+ print_json({ data: { mode, report } })
168
+ } else {
169
+ print_file_table(classified)
170
+ if (report.summary.conflicted > 0) {
171
+ console.error('')
172
+ print_warn(`${report.summary.conflicted} file(s) modified on both sides`)
173
+ }
174
+ }
175
+
176
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
177
+
178
+ module.exports = { run }
@@ -32,6 +32,7 @@ Options:
32
32
  --bump <type> Auto-bump version before publishing (patch, minor, major)
33
33
  --workspace <slug> Target workspace (overrides lock file owner)
34
34
  --public Publish as public (default is private)
35
+ --force Bypass divergence check (may overwrite remote changes)
35
36
 
36
37
  Aliases: pub
37
38
 
@@ -153,6 +154,26 @@ const run = (args) => catch_errors('Publish failed', async () => {
153
154
  }
154
155
  }
155
156
 
157
+ // Read base_commit from lock file for divergence check
158
+ const full_name_pre = `${workspace.slug}/${manifest.name}`
159
+ const project_root = find_project_root()
160
+ const [lock_err, lock_data] = await read_lock(project_root)
161
+ let base_commit = null
162
+ if (!lock_err && lock_data) {
163
+ const all_skills = get_all_locked_skills(lock_data)
164
+ const suffix = `/${skill_name}`
165
+ const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
166
+ if (lock_key && all_skills[lock_key]) {
167
+ base_commit = all_skills[lock_key].base_commit || null
168
+ }
169
+ }
170
+
171
+ const force = !!args.flags.force
172
+ if (force) {
173
+ print_warn('Force publishing — this may overwrite remote changes that haven\'t been merged.')
174
+ print_hint(`Consider ${code('happyskills pull')} first to merge safely.`)
175
+ }
176
+
156
177
  spinner.update('Packaging skill...')
157
178
  const [collect_err, skill_files] = await collect_files(dir)
158
179
  if (collect_err) { spinner.fail('Failed to collect files'); throw e('File collection failed', collect_err) }
@@ -166,15 +187,26 @@ const run = (args) => catch_errors('Publish failed', async () => {
166
187
  version: manifest.version,
167
188
  message: `Release ${manifest.version}`,
168
189
  files: skill_files,
169
- visibility
190
+ visibility,
191
+ base_commit: force ? null : base_commit,
192
+ force
170
193
  }, on_progress)
171
- if (push_err) { spinner.fail('Publish failed'); throw e('Push failed', push_err) }
194
+ if (push_err) {
195
+ const last = push_err[push_err.length - 1]
196
+ if (last?.message?.includes('diverged') || last?.message?.includes('DIVERGED')) {
197
+ spinner.fail('Remote has diverged')
198
+ print_error('Remote has newer changes. Run \'happyskills pull\' to merge, then publish again.')
199
+ print_hint(`Or use ${code('happyskills publish ' + skill_name + ' --force')} to overwrite remote changes.`)
200
+ return process.exit(EXIT_CODES.ERROR)
201
+ }
202
+ spinner.fail('Publish failed')
203
+ throw e('Push failed', push_err)
204
+ }
172
205
 
173
206
  spinner.succeed(`Published ${workspace.slug}/${manifest.name}@${manifest.version}`)
174
207
 
175
- const full_name = `${workspace.slug}/${manifest.name}`
176
- const project_root = find_project_root()
177
- const [lock_err, lock_data] = await read_lock(project_root)
208
+ // Update lock file: set base_commit and base_integrity to new values
209
+ const full_name = full_name_pre
178
210
  if (!lock_err && lock_data) {
179
211
  const all_skills = get_all_locked_skills(lock_data)
180
212
  const suffix = `/${skill_name}`
@@ -185,7 +217,9 @@ const run = (args) => catch_errors('Publish failed', async () => {
185
217
  ...all_skills[lock_key],
186
218
  version: manifest.version,
187
219
  ref: push_data?.ref || `refs/tags/v${manifest.version}`,
188
- commit: push_data?.commit || null
220
+ commit: push_data?.commit || null,
221
+ base_commit: push_data?.commit || null,
222
+ base_integrity: (!hash_err && integrity) ? integrity : null
189
223
  }
190
224
  if (!hash_err && integrity) updated_entry.integrity = integrity
191
225
  const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
@@ -196,6 +230,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
196
230
  ref: push_data?.ref || `refs/tags/v${manifest.version}`,
197
231
  commit: push_data?.commit || null,
198
232
  integrity: (!hash_err && integrity) ? integrity : null,
233
+ base_commit: push_data?.commit || null,
234
+ base_integrity: (!hash_err && integrity) ? integrity : null,
199
235
  requested_by: ['__root__'],
200
236
  dependencies: manifest.dependencies || {}
201
237
  }