happyskills 0.23.0 → 0.25.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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.25.0] - 2026-03-30
11
+
12
+ ### Added
13
+ - Add `--agents` flag to `init` and `convert` commands for explicit agent targeting, matching `install` behavior
14
+ - Add `recommendations` array to `validate` description max_length error — provides a 4-step procedure (audit, lossless compression, lossy compression, verify) for safely shortening descriptions without breaking auto-invocation triggers
15
+
16
+ ### Fixed
17
+ - Fix `init` not creating symlinks to secondary agents (Cursor, Windsurf, Codex, etc.) — newly scaffolded skills now use the same multi-agent linking as `install`
18
+ - Fix `convert` not creating symlinks to secondary agents — converted skills now get symlinked to all detected agents
19
+
20
+ ## [0.24.0] - 2026-03-30
21
+
22
+ ### Added
23
+ - Add merge commit support to `pull` and `publish` — when `pull` auto-merges local and remote changes without conflicts, the lock file stores `merge_parents` (the old base and remote head). On `publish`, these are sent as `parent_shas` to create a two-parent merge commit, preserving DAG history. Conflicts fall back to rebase semantics (single-parent commit). `merge_parents` is cleared after publish, on fast-forward, or when conflicts exist.
24
+
10
25
  ## [0.23.0] - 2026-03-30
11
26
 
12
27
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.23.0",
3
+ "version": "0.25.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,15 @@ const estimate_payload_size = (files) => {
12
12
  return size
13
13
  }
14
14
 
15
- const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force }, on_progress) =>
15
+ const smart_push = (owner, repo, { version, message, files, visibility, base_commit, force, parent_shas }, 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, base_commit, force })
21
+ const body = { version, message, files, visibility, base_commit, force }
22
+ if (parent_shas) body.parent_shas = parent_shas
23
+ const [err, data] = await repos_api.push(owner, repo, body)
22
24
  if (err) throw e('Direct push failed', err)
23
25
  return data
24
26
  }
@@ -27,9 +29,9 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
27
29
  const file_meta = files.map(f => ({ path: f.path, sha: f.sha, size: f.size }))
28
30
 
29
31
  // Step 1: Initiate
30
- const [init_err, init_data] = await initiate_upload(owner, repo, {
31
- version, message, files: file_meta, visibility, base_commit, force
32
- })
32
+ const init_body = { version, message, files: file_meta, visibility, base_commit, force }
33
+ if (parent_shas) init_body.parent_shas = parent_shas
34
+ const [init_err, init_data] = await initiate_upload(owner, repo, init_body)
33
35
  if (init_err) throw e('Upload initiation failed', init_err)
34
36
 
35
37
  const { upload_id, presigned_urls } = init_data
@@ -52,9 +54,9 @@ const smart_push = (owner, repo, { version, message, files, visibility, base_com
52
54
  if (upload_err) throw e('File uploads failed', upload_err)
53
55
 
54
56
  // Step 3: Complete
55
- const [complete_err, complete_data] = await complete_upload(owner, repo, {
56
- upload_id, version, message, files: file_meta, base_commit, force
57
- })
57
+ const complete_body = { upload_id, version, message, files: file_meta, base_commit, force }
58
+ if (parent_shas) complete_body.parent_shas = parent_shas
59
+ const [complete_err, complete_data] = await complete_upload(owner, repo, complete_body)
58
60
  if (complete_err) throw e('Upload completion failed', complete_err)
59
61
 
60
62
  return complete_data
@@ -13,6 +13,7 @@ const { write_lock, update_lock_skills } = require('../lock/writer')
13
13
  const { hash_directory } = require('../lock/integrity')
14
14
  const { skills_dir, find_project_root, lock_root } = require('../config/paths')
15
15
  const { file_exists } = require('../utils/fs')
16
+ const { resolve_agents, link_to_agents } = require('../agents')
16
17
  const { create_spinner } = require('../ui/spinner')
17
18
  const { print_help, print_success, print_error, print_info, print_warn, print_label, print_json } = require('../ui/output')
18
19
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
@@ -26,10 +27,12 @@ Arguments:
26
27
  skill-name Name of the skill in .claude/skills/
27
28
 
28
29
  Options:
29
- -g, --global Look in global skills (~/.claude/skills/)
30
+ -g, --global Look in global skills (~/.agents/skills/)
30
31
  --workspace <slug> Target workspace (if you have multiple)
31
32
  --version <ver> Initial version (default: 1.0.0)
32
33
  --keywords <tags> Comma-separated keywords (e.g., "deploy,aws,iac")
34
+ --agents <list> Target specific agents (comma-separated, e.g., claude,cursor)
35
+ Default: auto-detect. Env: HAPPYSKILLS_AGENTS
33
36
  -y, --yes Skip confirmation prompt
34
37
 
35
38
  Examples:
@@ -190,8 +193,25 @@ const run = (args) => catch_errors('Convert failed', async () => {
190
193
  const [lock_err] = await write_lock(lock_dir, new_skills)
191
194
  if (lock_err) { pub_spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_err) }
192
195
 
196
+ // Link to detected agents (non-fatal — warnings only)
197
+ const linked_agents = []
198
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined)
199
+ if (!agents_err && agents_result?.agents?.length > 0) {
200
+ pub_spinner.update(`Linking to ${agents_result.agents.length} agent(s)...`)
201
+ const [link_errs] = await link_to_agents(skill_dir, agents_result.agents, { global: is_global, project_root, skill_name })
202
+ if (link_errs) {
203
+ print_warn(`Warning: failed to link ${skill_name} to some agents`)
204
+ } else {
205
+ linked_agents.push(...agents_result.agents.map(a => a.id))
206
+ }
207
+ }
208
+
193
209
  pub_spinner.succeed(`Converted ${full_name}@${merged_version}`)
194
210
 
211
+ if (linked_agents.length > 0) {
212
+ print_info(`Linked to: ${agents_result.agents.map(a => a.display_name).join(', ')}`)
213
+ }
214
+
195
215
  if (args.flags.json) {
196
216
  const json_data = {
197
217
  skill: full_name,
@@ -199,6 +219,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
199
219
  workspace: workspace.slug,
200
220
  description: merged_description || ''
201
221
  }
222
+ if (linked_agents.length > 0) json_data.linked_agents = linked_agents
202
223
  if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
203
224
  print_json({ data: json_data })
204
225
  return
@@ -2,26 +2,29 @@ const path = require('path')
2
2
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
3
3
  const { write_manifest } = require('../manifest/writer')
4
4
  const { write_file, file_exists, ensure_dir } = require('../utils/fs')
5
- const { print_success, print_error, print_help, print_hint, print_json, code } = require('../ui/output')
5
+ const { print_success, print_error, print_help, print_hint, print_json, print_info, print_warn, code } = require('../ui/output')
6
6
  const { exit_with_error, CliError, UsageError } = require('../utils/errors')
7
7
  const { SKILL_JSON, SKILL_MD, EXIT_CODES, SKILL_TYPES, KIT_PREFIX } = require('../constants')
8
8
  const { find_project_root, skills_dir } = require('../config/paths')
9
+ const { resolve_agents, link_to_agents } = require('../agents')
9
10
 
10
11
  const HELP_TEXT = `Usage: happyskills init <name> [options]
11
12
 
12
- Scaffold a new skill in .claude/skills/<name>/.
13
+ Scaffold a new skill in .agents/skills/<name>/.
13
14
 
14
15
  Creates:
15
- .claude/skills/<name>/skill.json Skill manifest (name, version, deps)
16
- .claude/skills/<name>/SKILL.md Skill instructions for AI agents
16
+ .agents/skills/<name>/skill.json Skill manifest (name, version, deps)
17
+ .agents/skills/<name>/SKILL.md Skill instructions for AI agents
17
18
 
18
19
  Arguments:
19
20
  name Skill name (required, e.g., my-deploy-skill)
20
21
 
21
22
  Options:
22
- -g, --global Create in global skills (~/.claude/skills/)
23
- --kit Initialize as a kit (collection of skills)
24
- --json Output as JSON
23
+ -g, --global Create in global skills (~/.agents/skills/)
24
+ --kit Initialize as a kit (collection of skills)
25
+ --agents <list> Target specific agents (comma-separated, e.g., claude,cursor)
26
+ Default: auto-detect. Env: HAPPYSKILLS_AGENTS
27
+ --json Output as JSON
25
28
 
26
29
  Examples:
27
30
  happyskills init my-deploy-skill
@@ -104,14 +107,31 @@ const run = (args) => catch_errors('Init failed', async () => {
104
107
 
105
108
  const label = is_kit ? 'kit' : 'skill'
106
109
 
110
+ // Link to detected agents (non-fatal — warnings only)
111
+ const linked_agents = []
112
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined)
113
+ if (!agents_err && agents_result?.agents?.length > 0) {
114
+ const [link_errs] = await link_to_agents(dir, agents_result.agents, { global: is_global, project_root, skill_name: final_name })
115
+ if (link_errs) {
116
+ print_warn(`Warning: failed to link ${final_name} to some agents`)
117
+ } else {
118
+ linked_agents.push(...agents_result.agents.map(a => a.id))
119
+ }
120
+ }
121
+
107
122
  if (args.flags.json) {
108
- print_json({ data: { name: final_name, type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL, files_created, directory: dir } })
123
+ const data = { name: final_name, type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL, files_created, directory: dir }
124
+ if (linked_agents.length > 0) data.linked_agents = linked_agents
125
+ print_json({ data })
109
126
  return
110
127
  }
111
128
 
112
129
  print_success(`Initialized ${label} "${final_name}" at ${dir}`)
113
130
  console.log(` ${SKILL_JSON} — manifest`)
114
131
  console.log(` ${SKILL_MD} — ${is_kit ? 'kit description' : 'instructions'}`)
132
+ if (linked_agents.length > 0) {
133
+ print_info(`Linked to: ${agents_result.agents.map(a => a.display_name).join(', ')}`)
134
+ }
115
135
  console.log()
116
136
  print_hint(`Edit these files, then run ${code('happyskills publish')} to share.`)
117
137
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
@@ -158,17 +158,19 @@ const run = (args) => catch_errors('Publish failed', async () => {
158
158
  }
159
159
  }
160
160
 
161
- // Read base_commit from lock file for divergence check
161
+ // Read base_commit and merge_parents from lock file for divergence check
162
162
  const full_name_pre = `${workspace.slug}/${manifest.name}`
163
163
  const project_root = find_project_root()
164
164
  const [lock_err, lock_data] = await read_lock(project_root)
165
165
  let base_commit = null
166
+ let merge_parents = null
166
167
  if (!lock_err && lock_data) {
167
168
  const all_skills = get_all_locked_skills(lock_data)
168
169
  const suffix = `/${skill_name}`
169
170
  const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
170
171
  if (lock_key && all_skills[lock_key]) {
171
172
  base_commit = all_skills[lock_key].base_commit || null
173
+ merge_parents = all_skills[lock_key].merge_parents || null
172
174
  }
173
175
  }
174
176
 
@@ -187,14 +189,18 @@ const run = (args) => catch_errors('Publish failed', async () => {
187
189
  spinner.update(`Uploading files (${completed}/${total})...`)
188
190
  }
189
191
  const visibility = args.flags.public ? 'public' : 'private'
190
- const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, {
192
+ const push_options = {
191
193
  version: manifest.version,
192
194
  message: `Release ${manifest.version}`,
193
195
  files: skill_files,
194
196
  visibility,
195
197
  base_commit: force ? null : base_commit,
196
198
  force
197
- }, on_progress)
199
+ }
200
+ if (merge_parents && !force) {
201
+ push_options.parent_shas = merge_parents
202
+ }
203
+ const [push_err, push_data] = await smart_push(workspace.slug, manifest.name, push_options, on_progress)
198
204
  if (push_err) {
199
205
  const last = push_err[push_err.length - 1]
200
206
  if (last?.message?.includes('diverged') || last?.message?.includes('DIVERGED')) {
@@ -226,6 +232,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
226
232
  base_integrity: (!hash_err && integrity) ? integrity : null
227
233
  }
228
234
  if (!hash_err && integrity) updated_entry.integrity = integrity
235
+ delete updated_entry.merge_parents
236
+ delete updated_entry.conflict_files
229
237
  const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
230
238
  await write_lock(project_root, updated_skills)
231
239
  } else {
@@ -205,6 +205,8 @@ const run = (args) => catch_errors('Pull failed', async () => {
205
205
  version: cmp_data.head_version || lock_entry.version,
206
206
  ref: clone_data.ref || lock_entry.ref
207
207
  }
208
+ delete updated_entry.merge_parents
209
+ delete updated_entry.conflict_files
208
210
  const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
209
211
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
210
212
  if (wl_err) { spinner.fail('Failed to write lock file'); throw wl_err[0] }
@@ -440,8 +442,10 @@ const run = (args) => catch_errors('Pull failed', async () => {
440
442
  }
441
443
  if (conflict_files.length > 0) {
442
444
  updated_entry.conflict_files = conflict_files
445
+ delete updated_entry.merge_parents // Conflicts → rebase fallback
443
446
  } else {
444
447
  delete updated_entry.conflict_files
448
+ updated_entry.merge_parents = [lock_entry.base_commit, cmp_data.head_commit]
445
449
  }
446
450
  const merged_skills = update_lock_skills(lock_data, { [skill_name]: updated_entry })
447
451
  const [wl_err] = await write_lock(lock_root(is_global, project_root), merged_skills)
@@ -74,7 +74,19 @@ const validate_description = (fm) => {
74
74
  }
75
75
 
76
76
  if (desc.length > 1024) {
77
- results.push(result('description', 'max_length', 'error', `Description is ${desc.length} chars (max 1024)`, desc))
77
+ const deficit = desc.length - 1024
78
+ results.push({
79
+ ...result('description', 'max_length', 'error', `Description is ${desc.length} chars (max 1024). Must reduce by ${deficit} chars.`, desc),
80
+ recommendations: [
81
+ 'STEP 1 - AUDIT: Before changing anything, read the skill\'s routing table or capability list. Map each phrase in the description to the capability it triggers. Mark each phrase as: IDENTITY (describes what the skill is, usually one phrase), UNIQUE (the only phrase matching a specific capability), or REINFORCING (overlaps with another phrase\'s coverage).',
82
+ 'STEP 2 - LOSSLESS COMPRESSION: Apply these transformations that reduce characters without changing semantic meaning: (a) Remove articles (a, an, the). (b) Remove possessives (my, your) when the subject is implied. (c) Remove filler verbs (do, does, can, have, is, am). (d) Merge parallel structures that share the same verb (e.g., \'install kit. publish kit\' becomes \'install, publish kits\') or the same object (e.g., \'find kits, search kits\' becomes \'find, search kits\'). Stop here if now under the limit.',
83
+ 'STEP 3 - LOSSY COMPRESSION (only if still over the limit after step 2): Remove REINFORCING phrases only. When two phrases overlap semantically, remove the more general one and keep the more specific one. In synonym clusters, keep the two most commonly used verbs and remove the rest.',
84
+ 'NEVER remove an IDENTITY phrase — it anchors the understanding of the skill\'s purpose and provides context for all other trigger phrases.',
85
+ 'NEVER remove a UNIQUE trigger phrase — it is the only semantic path to that capability. Removing it makes that capability unreachable via auto-invocation.',
86
+ 'NEVER rephrase a trigger in a way that changes the core verb or noun (e.g., \'how many skills installed\' must not become \'skill count\'). The original phrasing matches how users naturally speak.',
87
+ 'STEP 4 - VERIFY: Cross-check the shortened description against the skill\'s routing table or capability list. Every documented capability must still have at least one semantically matching phrase in the description. If any capability lost coverage, restore its trigger phrase and find different savings.'
88
+ ]
89
+ })
78
90
  } else {
79
91
  results.push(result('description', 'max_length', 'pass', `Description: ${desc.length} chars (max 1024)`))
80
92
  }