happyskills 1.7.1 → 1.8.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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.8.0] - 2026-06-05
11
+
12
+ ### Changed
13
+
14
+ - Record installs via a cache-immune `install.completed` beacon. The install engine now emits one `install.completed` event per newly-installed skill — the root **and** each transitive dependency — batched into a single `POST /events`, carrying the skill's `repo_id`, version, and a root-vs-dependency flag. Because it fires from the engine, `setup` is now counted too (previously it emitted nothing). This replaces the per-command `install.completed` event and is the new source of truth for install/download counts, fixing the under-count caused by CDN cache hits on the clone endpoint silently skipping the old counting path. Fire-and-forget — a telemetry failure never blocks or fails an install; an already-up-to-date (no-op) install emits nothing. Requires API v5.5.0+ for `repo_id` stamping and download-count crediting; against older APIs the events are still sent (forward-compatible).
15
+
10
16
  ## [1.7.1] - 2026-06-04
11
17
 
12
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.7.1",
3
+ "version": "1.8.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)",
@@ -214,7 +214,10 @@ const run = (args) => catch_errors('Install failed', async () => {
214
214
  emit_analytics('install.failed', { error_code: 'INSTALL_FAILED', cli_version: CLI_VERSION })
215
215
  } else {
216
216
  results.push({ skill, result })
217
- emit_analytics('install.completed', { cli_version: CLI_VERSION })
217
+ // install.completed is now emitted per-skill from the install engine
218
+ // (cli/src/engine/installer.js, spec 260604-01) — carrying repo_id,
219
+ // version, and a root-vs-dependency flag — so setup (which bypasses
220
+ // this command) is covered too. No per-command emit here.
218
221
  }
219
222
  }
220
223
 
@@ -16,6 +16,8 @@ const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('
16
16
  const { is_skill_enabled } = require('../agents/status')
17
17
  const { create_spinner } = require('../ui/spinner')
18
18
  const { print_success, print_warn, print_info } = require('../ui/output')
19
+ const { emit_batch } = require('../utils/analytics')
20
+ const { CLI_VERSION } = require('../constants')
19
21
 
20
22
  const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
21
23
  const hint = dep.install_hint ? ` (install: ${dep.install_hint})` : ''
@@ -316,6 +318,26 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
316
318
  }
317
319
  }
318
320
 
321
+ // Cache-immune install counting (spec 260604-01). Fire one
322
+ // install.completed beacon per newly-installed skill — root AND each
323
+ // transitive dependency — batched into a single POST /events. This is the
324
+ // source of truth for install/download counts: it replaces the clone-
325
+ // endpoint side-effect that CloudFront cache HITs silently dropped. Only
326
+ // reached on a genuine install (no_op paths returned earlier and emit
327
+ // nothing). Fire-and-forget — never blocks or fails the install.
328
+ emit_batch(downloaded.map(({ pkg }) => {
329
+ const requested_by = updates[pkg.skill]?.requested_by || []
330
+ return {
331
+ event_type: 'install.completed',
332
+ repo_id: pkg.repo_id || null,
333
+ metadata: {
334
+ version: pkg.version,
335
+ dependency: !requested_by.includes('__root__'),
336
+ cli_version: CLI_VERSION,
337
+ },
338
+ }
339
+ }))
340
+
319
341
  spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
320
342
 
321
343
  const linked_count = downloaded.length - disabled_skills.size
@@ -7,25 +7,35 @@
7
7
 
8
8
  const { error: { catch_errors } } = require('puffy-core')
9
9
 
10
- const post_event = (event_type, metadata) => catch_errors('Failed to post analytics event', async () => {
10
+ const post_events = (events) => catch_errors('Failed to post analytics events', async () => {
11
11
  const { post } = require('../api/client')
12
- // Use unwrap:false so we don't trip on the empty body.
13
- await post('/events', { events: [{
14
- event_type,
15
- metadata: metadata || {},
12
+ const stamped = events.map(ev => ({
13
+ event_type: ev.event_type,
14
+ ...(ev.repo_id ? { repo_id: ev.repo_id } : {}),
15
+ metadata: ev.metadata || {},
16
16
  client_ts: Date.now(),
17
- }] }, { auth: true, unwrap: false })
17
+ }))
18
+ // Use unwrap:false so we don't trip on the empty body.
19
+ await post('/events', { events: stamped }, { auth: true, unwrap: false })
18
20
  })
19
21
 
20
22
  // Caller-friendly wrapper. Errors are swallowed — engagement telemetry must
21
23
  // never block the user.
22
- const emit = (event_type, metadata) => {
24
+ const emit = (event_type, metadata) => emit_batch([{ event_type, metadata }])
25
+
26
+ // Batched emit — sends N events in a single POST /events. Used by the install
27
+ // engine to record one install.completed per installed skill (root + each
28
+ // transitive dependency) in one request (spec 260604-01). Each event may carry
29
+ // a top-level `repo_id` (UUID); per-event metadata is passed through as-is.
30
+ const emit_batch = (events) => {
31
+ if (!Array.isArray(events) || events.length === 0) return
23
32
  // Don't return the promise — fire-and-forget. Errors stay quiet.
24
- post_event(event_type, metadata).then(([errors]) => {
33
+ post_events(events).then(([errors]) => {
25
34
  if (errors && process.env.HAPPYSKILLS_DEBUG) {
26
- process.stderr.write(`[analytics] ${event_type} failed: ${errors[0]?.message || 'unknown'}\n`)
35
+ const types = events.map(e => e.event_type).join(',')
36
+ process.stderr.write(`[analytics] ${types} failed: ${errors[0]?.message || 'unknown'}\n`)
27
37
  }
28
38
  }).catch(() => {})
29
39
  }
30
40
 
31
- module.exports = { emit }
41
+ module.exports = { emit, emit_batch }
@@ -0,0 +1,89 @@
1
+ // Run with: node --test src/utils/analytics.test.js (also covered by `npm test`)
2
+ //
3
+ // Unit tests for the batched analytics emitter (spec 260604-01). The install
4
+ // engine fires one install.completed event per installed skill (root + each
5
+ // dependency) batched into a SINGLE POST /events, with repo_id riding the
6
+ // top-level event field and version/dependency/cli_version in metadata. The
7
+ // emitter is fire-and-forget — it must never throw into the caller.
8
+
9
+ const { describe, it, afterEach } = require('node:test')
10
+ const assert = require('node:assert')
11
+
12
+ // Stub the API client before requiring the module under test, so we capture the
13
+ // exact POST payload emit_batch produces. analytics.js lazy-requires the client
14
+ // inside the post, so seeding require.cache here is enough.
15
+ const stub_client = (calls) => {
16
+ const client_path = require.resolve('../api/client')
17
+ require.cache[client_path] = {
18
+ id: client_path,
19
+ filename: client_path,
20
+ loaded: true,
21
+ exports: {
22
+ post: async (path, body, opts) => { calls.push({ path, body, opts }); return [null, null] },
23
+ },
24
+ }
25
+ delete require.cache[require.resolve('./analytics')]
26
+ return require('./analytics')
27
+ }
28
+
29
+ const restore = () => {
30
+ delete require.cache[require.resolve('../api/client')]
31
+ delete require.cache[require.resolve('./analytics')]
32
+ }
33
+
34
+ // emit_batch is fire-and-forget — give the microtask/promise chain a tick.
35
+ const tick = () => new Promise(r => setImmediate(r))
36
+
37
+ describe('emit_batch — install.completed beacon', () => {
38
+ afterEach(restore)
39
+
40
+ it('sends ONE POST /events for N skills, repo_id top-level, version+dependency in metadata', async () => {
41
+ const calls = []
42
+ const { emit_batch } = stub_client(calls)
43
+ emit_batch([
44
+ { event_type: 'install.completed', repo_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', metadata: { version: '1.0.0', dependency: false, cli_version: '9.9.9' } },
45
+ { event_type: 'install.completed', repo_id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', metadata: { version: '2.0.0', dependency: true, cli_version: '9.9.9' } },
46
+ ])
47
+ await tick()
48
+
49
+ assert.equal(calls.length, 1, 'a constellation install is a single batched request')
50
+ assert.equal(calls[0].path, '/events')
51
+ assert.equal(calls[0].opts.auth, true)
52
+ const evts = calls[0].body.events
53
+ assert.equal(evts.length, 2)
54
+ assert.equal(evts[0].repo_id, 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
55
+ assert.equal(evts[0].metadata.dependency, false)
56
+ assert.equal(evts[0].metadata.version, '1.0.0')
57
+ assert.equal(evts[1].repo_id, 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')
58
+ assert.equal(evts[1].metadata.dependency, true)
59
+ assert.ok(typeof evts[0].client_ts === 'number', 'each event is timestamped')
60
+ })
61
+
62
+ it('no-ops on an empty batch (zero installed skills → no request)', async () => {
63
+ const calls = []
64
+ const { emit_batch } = stub_client(calls)
65
+ emit_batch([])
66
+ await tick()
67
+ assert.equal(calls.length, 0)
68
+ })
69
+
70
+ it('omits repo_id from the wire when it is null/absent', async () => {
71
+ const calls = []
72
+ const { emit_batch } = stub_client(calls)
73
+ emit_batch([{ event_type: 'install.completed', repo_id: null, metadata: { version: '1.0.0' } }])
74
+ await tick()
75
+ assert.equal('repo_id' in calls[0].body.events[0], false)
76
+ })
77
+
78
+ it('swallows a client error — telemetry never throws into the install flow', async () => {
79
+ const client_path = require.resolve('../api/client')
80
+ require.cache[client_path] = {
81
+ id: client_path, filename: client_path, loaded: true,
82
+ exports: { post: async () => { throw new Error('network down') } },
83
+ }
84
+ delete require.cache[require.resolve('./analytics')]
85
+ const { emit_batch } = require('./analytics')
86
+ assert.doesNotThrow(() => emit_batch([{ event_type: 'install.completed', metadata: {} }]))
87
+ await tick()
88
+ })
89
+ })