happyskills 1.12.1 → 1.13.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,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.13.0] - 2026-06-29
11
+
12
+ ### Added
13
+
14
+ - Add `list --all-scopes`, which merges project-local and global skills into one listing instead of showing a single scope at a time (`list` = project, `list -g` = global). Every entry is tagged with `scope` (`local`/`global`) and `native` (`true` for managed/draft HappySkills skills, `false` for manually-added external/agent-orphan skills), and the human table gains **Scope** and **Native** columns — so you can see at a glance where each skill is installed and whether it's a native HappySkills skill or one added by hand. In this mode `data.skills` becomes an array (a skill installed in both scopes surfaces once per scope, rather than colliding on a shared `owner/name` key); the single-scope output shape is unchanged for backward compatibility.
15
+ - Add `update --all-scopes`, which updates outdated skills in **both** the project-local and global scopes in one run (local first, then global) and returns a per-scope report: `{ all_scopes: true, scopes: [{ scope: "local", … }, { scope: "global", … }], updated_count, outdated_count }`. Each `scopes[]` entry carries the same payload a single-scope update emits, so partial failure (e.g. local succeeds, global has an error) is visible per scope. Unlike `list --all-scopes`, this is opt-in, not the default — a write should not touch shared global state implicitly, so `update` stays project-local unless `-g` (global) or `--all-scopes` (both) is passed. The single-scope output shape is unchanged.
16
+ - Add passive skill-drift awareness, so you're told when your installed skills have fallen behind the registry without having to remember to check. Before each command, the CLI does a once-per-24h, fire-and-forget, zero-latency check (a synchronous read of a `~/.config/happyskills/skill-update-check.json` cache; a stale cache triggers a background refresh for next time, never blocking the current command) of installed root skills against the registry, spanning **both** the project-local and global locks so a globally-installed constellation is surfaced even during project-scoped work. Outdated skills appear two ways: a one-line nudge on stderr (text mode) and a `SKILLS_UPDATE_AVAILABLE` entry in the existing `warnings[]` array of every command's envelope (`--json` mode) — `data` is untouched and the envelope schema is unchanged. The suggested command is scope-aware (`update --all`, adding `-g` when global skills are behind). Only registry-advance (`outdated`) is reported — never a locally-bumped (`ahead`) or drifted skill. Disable with `HAPPYSKILLS_NO_SKILL_UPDATE_CHECK=1`.
17
+
10
18
  ## [1.12.1] - 2026-06-23
11
19
 
12
20
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.12.1",
3
+ "version": "1.13.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)",
@@ -1,3 +1,4 @@
1
+ const path = require('path')
1
2
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
3
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
4
  const { verify_lock_disk_consistency, detect_ahead_state } = require('../lock/verify')
@@ -17,7 +18,10 @@ const HELP_TEXT = `Usage: happyskills list [options]
17
18
  List installed skills.
18
19
 
19
20
  Options:
20
- -g, --global List globally installed skills
21
+ -g, --global List globally installed skills (~/.agents/skills/)
22
+ --all-scopes List BOTH project-local and global skills in one view,
23
+ each row tagged with its scope (local/global) and whether
24
+ it is a native HappySkills skill or a manually-added one
21
25
  --json Output as JSON
22
26
 
23
27
  Aliases: ls
@@ -25,16 +29,23 @@ Aliases: ls
25
29
  Examples:
26
30
  happyskills list
27
31
  happyskills ls --json
28
- happyskills ls -g`
32
+ happyskills ls -g
33
+ happyskills ls --all-scopes --json`
29
34
 
30
- const run = (args) => catch_errors('List failed', async () => {
31
- if (args.flags._show_help) {
32
- print_help(HELP_TEXT)
33
- return process.exit(EXIT_CODES.SUCCESS)
34
- }
35
+ // scope_tag the public, machine-readable scope label for an entry.
36
+ const scope_tag = (is_global) => (is_global ? 'global' : 'local')
37
+ // scope_label — the human, title-cased scope label for the table.
38
+ const scope_label = (is_global) => (is_global ? 'Global' : 'Local')
35
39
 
36
- const is_global = args.flags.global || false
37
- const project_root = find_project_root()
40
+ // gather_scope collect the full, render-ready inventory for ONE scope
41
+ // (project or global). Returns normalized "views" so the JSON and human
42
+ // renderers share one computation of status / type / enabled, rather than
43
+ // each re-deriving it. The four buckets mirror `list`'s data contract:
44
+ // managed_views → native, recorded in skills-lock.json (data.skills)
45
+ // draft_skills → native, scaffolded by `init`, not yet published (data.drafts)
46
+ // external_skills→ manual/foreign on disk (data.external)
47
+ // orphan_skills → manual/foreign dropped into an agent folder (data.agent_orphans)
48
+ const gather_scope = (is_global, project_root, agents_flag) => catch_errors('Gather scope failed', async () => {
38
49
  const base_dir = skills_dir(is_global, project_root)
39
50
 
40
51
  const [, lock_data] = await read_lock(lock_root(is_global, project_root))
@@ -42,7 +53,7 @@ const run = (args) => catch_errors('List failed', async () => {
42
53
  const managed_entries = Object.entries(skills)
43
54
 
44
55
  // Resolve agents and build enabled/disabled map for managed skills
45
- const [, agents_result] = await resolve_agents(args.flags.agents, { global: is_global, project_root })
56
+ const [, agents_result] = await resolve_agents(agents_flag, { global: is_global, project_root })
46
57
  const agents = agents_result?.agents || []
47
58
  const managed_short_names = managed_entries.map(([k]) => k.split('/')[1])
48
59
  const [, enabled_map] = agents.length > 0
@@ -65,31 +76,13 @@ const run = (args) => catch_errors('List failed', async () => {
65
76
  const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
66
77
  const orphan_skills = agent_orphans || []
67
78
 
68
- if (managed_entries.length === 0 && draft_skills.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
69
- if (args.flags.json) {
70
- print_json({ data: { skills: {}, drafts: [], external: [], agent_orphans: [] } })
71
- return
72
- }
73
- print_info('No skills installed.')
74
- return
75
- }
76
-
77
- // Resolve type for each managed entry from lock data or skill.json on disk
78
- const resolve_type = async (name, data) => {
79
- if (data.type) return data.type
80
- const dir = skill_install_dir(base_dir, name.split('/')[1])
81
- const json_path = require('path').join(dir, SKILL_JSON)
82
- const [, manifest] = await read_json(json_path)
83
- return manifest?.type || SKILL_TYPES.SKILL
84
- }
85
-
86
79
  // Compute drift AND ahead state up-front for every managed entry. Cheap
87
80
  // (one skill.json read per skill, plus an optional CHANGELOG read) and lets
88
81
  // both the JSON and human paths report identically.
89
82
  //
90
83
  // §10.5: drift is narrowed to genuine inconsistency (regression, missing
91
- // files); the disk-greater-than-lock case is reported under top-level
92
- // status:ahead, not under drift.
84
+ // files); the disk-greater-than-lock case is reported under status:ahead,
85
+ // not under drift.
93
86
  const drift_by_skill = {}
94
87
  const ahead_by_skill = {}
95
88
  await Promise.all(managed_entries.map(async ([name, data]) => {
@@ -104,86 +97,167 @@ const run = (args) => catch_errors('List failed', async () => {
104
97
  if (ahead && ahead.ahead) ahead_by_skill[name] = ahead
105
98
  }))
106
99
 
107
- if (args.flags.json) {
108
- const skills_map = {}
109
- for (const [name, data] of managed_entries) {
110
- const short = name.split('/')[1]
111
- const dir = skill_install_dir(base_dir, short)
112
- const [, exists] = await file_exists(dir)
113
- const drift = drift_by_skill[name]
114
- const ahead = ahead_by_skill[name]
115
- let status
116
- if (drift) status = 'drift'
117
- else if (ahead) status = 'ahead'
118
- else if (exists) status = 'installed'
119
- else status = 'missing'
120
- const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
121
- const type = await resolve_type(name, data)
122
- const enabled = enabled_map?.get(short) ?? true
123
- const entry = { version: data.version, type, source, status, enabled }
124
- if (drift) entry.drift = { reason: drift.reason, lock_version: drift.expected, disk_version: drift.actual }
125
- if (ahead) entry.ahead = {
126
- lock_version: ahead.lock_version,
127
- disk_version: ahead.disk_version,
128
- has_changelog_entry: ahead.has_changelog_entry || false,
129
- changelog_version: ahead.changelog_version || null
130
- }
131
- skills_map[name] = entry
132
- }
133
- const drafts = draft_skills.map(s => ({
134
- name: s.name,
135
- description: s.description || '',
136
- version: s.version || null,
137
- type: s.type || SKILL_TYPES.SKILL
138
- }))
139
- const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
140
- const agent_orphan_list = orphan_skills.map(s => ({
141
- name: s.name,
142
- description: s.description || '',
143
- agents: s.agents
144
- }))
145
- print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
146
- return
100
+ const resolve_type = async (name, data) => {
101
+ if (data.type) return data.type
102
+ const dir = skill_install_dir(base_dir, name.split('/')[1])
103
+ const [, manifest] = await read_json(path.join(dir, SKILL_JSON))
104
+ return manifest?.type || SKILL_TYPES.SKILL
147
105
  }
148
106
 
149
- const rows = []
150
- for (const [name, data] of managed_entries) {
107
+ // Normalize each managed entry into a single render-ready view. Both the
108
+ // JSON and human renderers consume these, so status/type/enabled are
109
+ // computed exactly once.
110
+ const managed_views = await Promise.all(managed_entries.map(async ([name, data]) => {
151
111
  const short = name.split('/')[1]
152
112
  const dir = skill_install_dir(base_dir, short)
153
113
  const [, exists] = await file_exists(dir)
154
114
  const drift = drift_by_skill[name]
155
115
  const ahead = ahead_by_skill[name]
156
- let status_label
157
- if (drift) status_label = red(`drift (${drift.reason})`)
158
- else if (ahead) status_label = `ahead (disk ${ahead.disk_version})`
159
- else if (exists) status_label = 'installed'
160
- else status_label = yellow('missing')
116
+ let status
117
+ if (drift) status = 'drift'
118
+ else if (ahead) status = 'ahead'
119
+ else if (exists) status = 'installed'
120
+ else status = 'missing'
161
121
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
162
122
  const type = await resolve_type(name, data)
163
- const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
164
123
  const enabled = enabled_map?.get(short) ?? true
165
- const enabled_label = enabled ? green('enabled') : yellow('disabled')
166
- rows.push([display_name, data.version, source, status_label, enabled_label])
124
+ return { name, short, version: data.version, type, source, status, enabled, drift, ahead }
125
+ }))
126
+
127
+ return {
128
+ is_global,
129
+ managed_views,
130
+ draft_skills,
131
+ external_skills,
132
+ orphan_skills,
167
133
  }
134
+ }).then(([errors, result]) => {
135
+ if (errors) throw e('Failed to gather scope', errors)
136
+ return result
137
+ })
168
138
 
169
- for (const s of draft_skills) {
170
- const type_label = s.type === SKILL_TYPES.KIT ? `${s.name} [kit]` : s.name
171
- rows.push([type_label, s.version || '-', 'draft', 'unpublished', '-'])
139
+ // json_managed_entry the canonical per-skill JSON shape, shared by the
140
+ // object form (single scope) and the array form (--all-scopes).
141
+ const json_managed_entry = (v) => {
142
+ const entry = { version: v.version, type: v.type, source: v.source, status: v.status, enabled: v.enabled }
143
+ if (v.drift) entry.drift = { reason: v.drift.reason, lock_version: v.drift.expected, disk_version: v.drift.actual }
144
+ if (v.ahead) entry.ahead = {
145
+ lock_version: v.ahead.lock_version,
146
+ disk_version: v.ahead.disk_version,
147
+ has_changelog_entry: v.ahead.has_changelog_entry || false,
148
+ changelog_version: v.ahead.changelog_version || null,
172
149
  }
150
+ return entry
151
+ }
152
+
153
+ const run = (args) => catch_errors('List failed', async () => {
154
+ if (args.flags._show_help) {
155
+ print_help(HELP_TEXT)
156
+ return process.exit(EXIT_CODES.SUCCESS)
157
+ }
158
+
159
+ const all_scopes = args.flags['all-scopes'] || false
160
+ const is_global = args.flags.global || false
161
+ const project_root = find_project_root()
162
+
163
+ // --all-scopes overrides -g: it always covers BOTH project and global.
164
+ const scope_flags = all_scopes ? [false, true] : [is_global]
165
+ const scopes = await Promise.all(scope_flags.map(g => gather_scope(g, project_root, args.flags.agents)))
166
+
167
+ const total_entries = scopes.reduce((n, s) =>
168
+ n + s.managed_views.length + s.draft_skills.length + s.external_skills.length + s.orphan_skills.length, 0)
173
169
 
174
- for (const s of external_skills) {
175
- rows.push([s.name, '-', 'external', 'installed', '-'])
170
+ if (total_entries === 0) {
171
+ if (args.flags.json) {
172
+ print_json({ data: all_scopes
173
+ ? { skills: [], drafts: [], external: [], agent_orphans: [] }
174
+ : { skills: {}, drafts: [], external: [], agent_orphans: [] } })
175
+ return
176
+ }
177
+ print_info('No skills installed.')
178
+ return
176
179
  }
177
180
 
178
- for (const s of orphan_skills) {
179
- const agent_label = s.agents.map(a => a.name).join(', ')
180
- rows.push([s.name, '-', `agent-orphan (${agent_label})`, 'installed', '-'])
181
+ if (args.flags.json) {
182
+ if (all_scopes) {
183
+ // Merged, collision-safe ARRAY form: a skill installed in BOTH scopes
184
+ // is genuinely two installs (separate lock files, possibly different
185
+ // versions / enabled state) and surfaces as two entries.
186
+ const skills = []
187
+ const drafts = []
188
+ const external = []
189
+ const agent_orphans = []
190
+ for (const s of scopes) {
191
+ const tag = scope_tag(s.is_global)
192
+ for (const v of s.managed_views) skills.push({ name: v.name, scope: tag, native: true, ...json_managed_entry(v) })
193
+ for (const d of s.draft_skills) drafts.push({ name: d.name, scope: tag, native: true, description: d.description || '', version: d.version || null, type: d.type || SKILL_TYPES.SKILL })
194
+ for (const x of s.external_skills) external.push({ name: x.name, scope: tag, native: false, description: x.description || '' })
195
+ for (const o of s.orphan_skills) agent_orphans.push({ name: o.name, scope: tag, native: false, description: o.description || '', agents: o.agents })
196
+ }
197
+ print_json({ data: { skills, drafts, external, agent_orphans } })
198
+ return
199
+ }
200
+
201
+ // Single-scope (default / -g): object-keyed `skills`, unchanged contract.
202
+ const s = scopes[0]
203
+ const skills_map = {}
204
+ for (const v of s.managed_views) skills_map[v.name] = json_managed_entry(v)
205
+ const drafts = s.draft_skills.map(d => ({
206
+ name: d.name,
207
+ description: d.description || '',
208
+ version: d.version || null,
209
+ type: d.type || SKILL_TYPES.SKILL,
210
+ }))
211
+ const external = s.external_skills.map(x => ({ name: x.name, description: x.description || '' }))
212
+ const agent_orphan_list = s.orphan_skills.map(o => ({
213
+ name: o.name,
214
+ description: o.description || '',
215
+ agents: o.agents,
216
+ }))
217
+ print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
218
+ return
219
+ }
220
+
221
+ // ── Human output ──────────────────────────────────────────────────────
222
+ // In --all-scopes mode each row is prefixed with Scope + Native cells
223
+ // (after the Skill name); push_row handles that uniformly so the per-bucket
224
+ // loops only describe the trailing columns.
225
+ const rows = []
226
+ const push_row = (s_label, native_cell, name_cell, tail) => {
227
+ rows.push(all_scopes ? [name_cell, s_label, native_cell, ...tail] : [name_cell, ...tail])
228
+ }
229
+ for (const s of scopes) {
230
+ const s_label = scope_label(s.is_global)
231
+ for (const v of s.managed_views) {
232
+ let status_label
233
+ if (v.drift) status_label = red(`drift (${v.drift.reason})`)
234
+ else if (v.ahead) status_label = `ahead (disk ${v.ahead.disk_version})`
235
+ else if (v.status === 'missing') status_label = yellow('missing')
236
+ else status_label = 'installed'
237
+ const display_name = v.type === SKILL_TYPES.KIT ? `${v.name} [kit]` : v.name
238
+ const enabled_label = v.enabled ? green('enabled') : yellow('disabled')
239
+ push_row(s_label, green('yes'), display_name, [v.version, v.source, status_label, enabled_label])
240
+ }
241
+ for (const d of s.draft_skills) {
242
+ const type_label = d.type === SKILL_TYPES.KIT ? `${d.name} [kit]` : d.name
243
+ push_row(s_label, green('yes'), type_label, [d.version || '-', 'draft', 'unpublished', '-'])
244
+ }
245
+ for (const x of s.external_skills) {
246
+ push_row(s_label, yellow('no'), x.name, ['-', 'external', 'installed', '-'])
247
+ }
248
+ for (const o of s.orphan_skills) {
249
+ const agent_label = o.agents.map(a => a.name).join(', ')
250
+ push_row(s_label, yellow('no'), o.name, ['-', `agent-orphan (${agent_label})`, 'installed', '-'])
251
+ }
181
252
  }
182
253
 
183
- print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
254
+ if (all_scopes) print_table(['Skill', 'Scope', 'Native', 'Version', 'Source', 'Status', 'Enabled'], rows)
255
+ else print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
184
256
 
185
- const drift_count = Object.keys(drift_by_skill).length
186
- const ahead_count = Object.keys(ahead_by_skill).length
257
+ // Aggregate the advisory tails across every gathered scope.
258
+ const drift_count = scopes.reduce((n, s) => n + s.managed_views.filter(v => v.drift).length, 0)
259
+ const ahead_count = scopes.reduce((n, s) => n + s.managed_views.filter(v => v.ahead && !v.drift).length, 0)
260
+ const draft_count = scopes.reduce((n, s) => n + s.draft_skills.length, 0)
187
261
  if (drift_count > 0) {
188
262
  console.log()
189
263
  print_warn(`${drift_count} skill(s) drifted: lock and on-disk skill.json disagree.`)
@@ -193,37 +267,46 @@ const run = (args) => catch_errors('List failed', async () => {
193
267
  console.log()
194
268
  print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
195
269
  }
196
- if (draft_skills.length > 0) {
270
+ if (draft_count > 0) {
197
271
  console.log()
198
- print_info(`${draft_skills.length} draft(s) ready to publish — run ${code('happyskills release <name>')} to ship.`)
272
+ print_info(`${draft_count} draft(s) ready to publish — run ${code('happyskills release <name>')} to ship.`)
199
273
  }
200
274
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
201
275
 
202
276
  const schema = {
203
277
  name: 'list',
204
278
  audience: 'consumer',
205
- purpose: 'List installed skills, showing version, source, drift/ahead status, and agent-enabled state.',
279
+ purpose: 'List installed skills, showing version, source, drift/ahead status, and agent-enabled state. With --all-scopes, merges project-local and global skills into one view, each tagged with its scope and whether it is a native HappySkills skill.',
206
280
  mutation: false,
207
281
  interactive_in_text_mode: false,
208
282
  input: {
209
283
  positional: [],
210
284
  flags: [
211
285
  { name: 'global', alias: 'g', type: 'boolean', default: false, description: 'List globally installed skills' },
286
+ { name: 'all-scopes', type: 'boolean', default: false, description: 'List both project-local and global skills, tagged with scope (local/global) and native (true/false)' },
212
287
  { name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
213
288
  ],
214
289
  },
215
290
  output: {
216
291
  data_shape: {
292
+ // Default / -g (single scope): `skills` is an object keyed by owner/name.
217
293
  skills: 'object<string, { version: string, type: string, source: string, status: string, enabled: boolean, drift?: object, ahead?: object }>',
218
294
  drafts: 'array<{ name: string, description: string, version: string|null, type: string }>',
219
295
  external: 'array<{ name: string, description: string }>',
220
296
  agent_orphans: 'array<{ name: string, description: string, agents: string[] }>',
297
+ // With --all-scopes: `skills` becomes an ARRAY and every bucket entry
298
+ // carries `scope: "local"|"global"` and `native: boolean`.
299
+ // skills: 'array<{ name, scope, native: true, version, type, source, status, enabled, drift?, ahead? }>'
300
+ // drafts: 'array<{ name, scope, native: true, description, version, type }>'
301
+ // external: 'array<{ name, scope, native: false, description }>'
302
+ // agent_orphans:'array<{ name, scope, native: false, description, agents }>'
221
303
  },
222
304
  },
223
305
  errors: [],
224
306
  examples: [
225
307
  'happyskills list',
226
308
  'happyskills ls --json',
309
+ 'happyskills ls --all-scopes --json',
227
310
  ],
228
311
  }
229
312