happyskills 0.52.1 → 0.54.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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.54.0] - 2026-05-26
11
+
12
+ ### Added
13
+
14
+ - **`intent_id` is now attached to every `/telemetry/discovery` beacon payload** (spec 260521-03 addendum § 4 — CLI half of Phase A). The CLI sources the value from the active intent envelope via a new `get_intent_id()` helper in `cli/src/utils/intent.js`, which decodes the envelope's base64url payload portion and extracts `intent_id`. Sent on all four beacon types fired from `search` and `postlex`: `rerank_started`, `rerank_completed`, `clarify_triggered`, `clarify_completed`. When no envelope is active the payload includes `intent_id: null`. Before this change, every `search.rerank` and `search.clarify` row in `analytics.events` landed with `intent_id = NULL`, defeating the addendum's goal of correlating beacons with the discovery chain.
15
+
16
+ ## [0.53.0] - 2026-05-25
17
+
18
+ ### Added
19
+
20
+ - **First-launch attribution prompt on `happyskills login`** (spec 260521-03 § 10.1). The first successful login from a fresh install prints a one-shot, single-keypress prompt asking "what brought you here?" (Reddit / HN / Twitter / YouTube / blog / podcast / friend / AI search / other). Skippable (Enter). The answer is emitted as a `cli.attribution_answered` event to the API and updates `users.signup_source` (only when previously NULL — never overwrites). A marker file at `$XDG_CONFIG_HOME/happyskills/attribution-prompted` prevents re-prompting on subsequent logins.
21
+ - **`auth.cli_first_link` event** emitted alongside the attribution prompt — the principal-to-operator handoff signal for the time-to-CLI-link metric.
22
+ - **Surface tagging on every API request.** All API calls now carry `X-Client-Surface: cli` and `X-Client-Version: <cli_version>` headers so the API can bucket analytics events by source.
23
+ - **Intent envelope threading** (spec 260521-03 § 8.4) — every API call attaches the active `X-Intent-Envelope` request header; the refreshed value returned by the server is captured from the response and used on the next call (sliding TTL). The envelope is propagated to subprocesses via the `HAPPYSKILLS_INTENT_ENVELOPE` env var or the new top-level `--intent <base64>` flag, so a parent process (e.g. MCP server) can correlate a multi-step discovery workflow into one trace.
24
+ - **Install/uninstall analytics events** — `install.started` / `install.completed` / `install.failed` (with `error_code` on failure) emitted from `happyskills install`; `uninstall` emitted from `happyskills uninstall`. Fire-and-forget — never block the user-visible flow.
25
+ - **`cli/src/utils/{analytics,attribution,intent}.js`** — internal modules powering the above. All three are CommonJS, lazy-loaded, and fail silently on network errors so analytics never affects exit codes.
26
+
10
27
  ## [0.52.1] - 2026-05-25
11
28
 
12
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.52.1",
3
+ "version": "0.54.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/client.js CHANGED
@@ -3,6 +3,7 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
3
3
  const { API_URL, CLI_VERSION } = require('../constants')
4
4
  const { ApiError, NetworkError, AuthError } = require('../utils/errors')
5
5
  const { load_token } = require('../auth/token_store')
6
+ const { get_envelope, set_envelope } = require('../utils/intent')
6
7
 
7
8
  const get_base_url = () => process.env.HAPPYSKILLS_API_URL || API_URL
8
9
  const USER_AGENT = `happyskills-cli/${CLI_VERSION}`
@@ -12,7 +13,19 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
12
13
  const url = `${get_base_url()}${path}`
13
14
  // User-Agent enables the API to identify CLI traffic (e.g. feedback API
14
15
  // derives `source = 'cli'` from this header). Spec 260524-01 § 12.
15
- const headers = { 'User-Agent': USER_AGENT, ...extra_headers }
16
+ // X-Client-Surface / X-Client-Version are required by spec 260521-03 § 10.2
17
+ // so analytics rows can be bucketed by source. X-Intent-Envelope threads
18
+ // the active discovery intent across calls per § 8.4.
19
+ const headers = {
20
+ 'User-Agent': USER_AGENT,
21
+ 'X-Client-Surface': 'cli',
22
+ 'X-Client-Version': CLI_VERSION,
23
+ ...extra_headers,
24
+ }
25
+ const active_envelope = get_envelope()
26
+ if (active_envelope && !headers['X-Intent-Envelope']) {
27
+ headers['X-Intent-Envelope'] = active_envelope
28
+ }
16
29
 
17
30
  if (auth) {
18
31
  const [, token_data] = await load_token()
@@ -42,6 +55,13 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
42
55
  throw new NetworkError(`Failed to connect to API: ${err.message}`)
43
56
  }
44
57
 
58
+ // Capture refreshed X-Intent-Envelope so subsequent calls (and any
59
+ // subprocess we later spawn via HAPPYSKILLS_INTENT_ENVELOPE) carry the
60
+ // updated TTL. Best-effort: missing header just means the endpoint
61
+ // doesn't refresh the envelope (most endpoints don't).
62
+ const refreshed_envelope = res.headers && res.headers.get && res.headers.get('x-intent-envelope')
63
+ if (refreshed_envelope) set_envelope(refreshed_envelope)
64
+
45
65
  if (raw_response) return res
46
66
 
47
67
  const data = await res.json().catch(() => null)
@@ -12,6 +12,8 @@ const { file_exists } = require('../utils/fs')
12
12
  const { hash_directory } = require('../lock/integrity')
13
13
  const { get_all_locked_skills } = require('../lock/reader')
14
14
  const snapshot_storage = require('../snapshot/storage')
15
+ const { emit: emit_analytics } = require('../utils/analytics')
16
+ const { CLI_VERSION } = require('../constants')
15
17
 
16
18
  const HELP_TEXT = `Usage: happyskills install [<owner>/<skill>[@<version>]] [...] [options]
17
19
 
@@ -184,14 +186,17 @@ const run = (args) => catch_errors('Install failed', async () => {
184
186
  const failures = []
185
187
  for (const { skill, version: inline_version } of parsed) {
186
188
  const version = flag_version || inline_version
189
+ emit_analytics('install.started', { cli_version: CLI_VERSION })
187
190
  const [errors, result] = await install(skill, { ...base_options, version })
188
191
  if (errors) {
189
192
  const chain = errors?.map ? errors.map(x => x?.message).filter(Boolean) : [errors?.message || String(errors)]
190
193
  const msg = chain[chain.length - 1] || chain[0] || 'Unknown error'
191
194
  print_warn(`Failed to install ${skill}: ${msg}`)
192
195
  failures.push({ skill, error: msg })
196
+ emit_analytics('install.failed', { error_code: 'INSTALL_FAILED', cli_version: CLI_VERSION })
193
197
  } else {
194
198
  results.push({ skill, result })
199
+ emit_analytics('install.completed', { cli_version: CLI_VERSION })
195
200
  }
196
201
  }
197
202
 
@@ -1,3 +1,5 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
1
3
  const readline = require('readline')
2
4
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
3
5
  const auth_api = require('../api/auth')
@@ -5,8 +7,44 @@ const { save_token, load_token } = require('../auth/token_store')
5
7
  const { create_spinner } = require('../ui/spinner')
6
8
  const { print_help, print_success, print_info, print_hint, print_json, code } = require('../ui/output')
7
9
  const { exit_with_error } = require('../utils/errors')
8
- const { EXIT_CODES } = require('../constants')
10
+ const { EXIT_CODES, CLI_VERSION } = require('../constants')
9
11
  const { run_browser_flow } = require('./login_device')
12
+ const { config_dir } = require('../config/paths')
13
+ const { prompt_for_source } = require('../utils/attribution')
14
+ const { emit } = require('../utils/analytics')
15
+
16
+ // Spec 260521-03 § 10.1 — One-shot first-launch attribution prompt. After
17
+ // the user has answered (or skipped), this marker file prevents re-prompting
18
+ // across subsequent logins on the same install.
19
+ const ATTRIBUTION_MARKER = () => path.join(config_dir(), 'attribution-prompted')
20
+
21
+ const attribution_already_prompted = () => {
22
+ try { return fs.existsSync(ATTRIBUTION_MARKER()) } catch { return false }
23
+ }
24
+
25
+ const mark_attribution_prompted = () => {
26
+ try {
27
+ fs.mkdirSync(config_dir(), { recursive: true })
28
+ fs.writeFileSync(ATTRIBUTION_MARKER(), new Date().toISOString())
29
+ } catch {
30
+ // Best-effort. Re-prompting once is annoying but not catastrophic.
31
+ }
32
+ }
33
+
34
+ // Fire-and-forget. Emits cli.attribution_answered + auth.cli_first_link.
35
+ const handle_post_login_telemetry = async () => {
36
+ // Always emit auth.cli_first_link on the first successful login from this
37
+ // install (gated by the same marker — once we've prompted/marked, we
38
+ // don't fire again).
39
+ if (attribution_already_prompted()) return
40
+ emit('auth.cli_first_link', { cli_version: CLI_VERSION })
41
+
42
+ const source = await prompt_for_source()
43
+ mark_attribution_prompted()
44
+ if (source) {
45
+ emit('cli.attribution_answered', { source })
46
+ }
47
+ }
10
48
 
11
49
  const HELP_TEXT = `Usage: happyskills login [options]
12
50
 
@@ -95,6 +133,7 @@ const run_password_flow = () => catch_errors('Password login failed', async () =
95
133
  if (save_err) { spinner.fail('Failed to save credentials'); throw e('Failed to save token', save_err) }
96
134
 
97
135
  spinner.succeed('Logged in successfully')
136
+ await handle_post_login_telemetry()
98
137
  })
99
138
 
100
139
  const decode_jwt_payload = (token) => {
@@ -170,6 +209,9 @@ const run = (args) => catch_errors('Login failed', async () => {
170
209
  } else {
171
210
  const [errors] = await run_browser_flow()
172
211
  if (errors) throw e('Browser login failed', errors)
212
+ // Browser flow doesn't go through run_password_flow's success branch,
213
+ // so we trigger the post-login telemetry here.
214
+ await handle_post_login_telemetry()
173
215
  }
174
216
 
175
217
  console.log()
@@ -15,6 +15,7 @@ const { exit_with_error, UsageError, CliError } = require('../utils/errors')
15
15
  const { EXIT_CODES } = require('../constants')
16
16
  const { slug_token_set, compute_lex_tier } = require('../utils/slug_tokens')
17
17
  const { fire_discovery_telemetry } = require('../api/telemetry')
18
+ const { get_intent_id } = require('../utils/intent')
18
19
 
19
20
  const HELP_TEXT = `Usage: happyskills postlex --query <q> --ranking <file|-> [options]
20
21
 
@@ -425,9 +426,13 @@ const run = (args) => catch_errors('Postlex failed', async () => {
425
426
  const next_step = determine_next_step(final_ordering, query, clarification_turns_used)
426
427
 
427
428
  // Fire telemetry — rerank_completed first (the rerank step succeeded);
428
- // clarify_triggered separately if applicable.
429
+ // clarify_triggered separately if applicable. `intent_id` is sourced from
430
+ // the active intent envelope so these beacons correlate with the same
431
+ // discovery chain (spec 260521-03 addendum § 4).
432
+ const intent_id = get_intent_id()
429
433
  fire_discovery_telemetry({
430
434
  event: 'rerank_completed',
435
+ intent_id,
431
436
  query,
432
437
  promoted,
433
438
  promoted_from_rank,
@@ -436,6 +441,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
436
441
  if (next_step.action === 'clarify') {
437
442
  fire_discovery_telemetry({
438
443
  event: 'clarify_triggered',
444
+ intent_id,
439
445
  query,
440
446
  reason: 'post_rerank_weak',
441
447
  turn_number: clarification_turns_used + 1,
@@ -6,6 +6,7 @@ const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
6
6
  const { EXIT_CODES, VALID_SKILL_TYPES } = require('../constants')
7
7
  const { load_token } = require('../auth/token_store')
8
8
  const { fire_discovery_telemetry } = require('../api/telemetry')
9
+ const { get_intent_id } = require('../utils/intent')
9
10
 
10
11
  const HELP_TEXT = `Usage: happyskills search [query] [options]
11
12
 
@@ -221,9 +222,14 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
221
222
  const next_step = build_search_next_step(response, query, { with_rerank, clarification_turns_used })
222
223
 
223
224
  // Telemetry beacons (fire-and-forget). Spec § 5.1 + § 5.3.
225
+ // `intent_id` is sourced from the active intent envelope (spec 260521-03
226
+ // addendum § 4) so search.rerank / search.clarify rows correlate with
227
+ // the same discovery chain as the search.query that minted the envelope.
228
+ const intent_id = get_intent_id()
224
229
  if (with_rerank && next_step?.action === 'rank_digests_inline') {
225
230
  fire_discovery_telemetry({
226
231
  event: 'rerank_started',
232
+ intent_id,
227
233
  query,
228
234
  rerank_prompt_version: response?.rerank_prompt_version || null,
229
235
  })
@@ -231,6 +237,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
231
237
  if (with_rerank && next_step?.action === 'clarify') {
232
238
  fire_discovery_telemetry({
233
239
  event: 'clarify_triggered',
240
+ intent_id,
234
241
  query,
235
242
  reason: 'match_notice',
236
243
  turn_number: clarification_turns_used + 1,
@@ -241,6 +248,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
241
248
  // with a refined query.
242
249
  fire_discovery_telemetry({
243
250
  event: 'clarify_completed',
251
+ intent_id,
244
252
  query,
245
253
  turn_number: clarification_turns_used,
246
254
  })
@@ -4,6 +4,7 @@ const { print_help, print_json, print_warn } = require('../ui/output')
4
4
  const { exit_with_error, UsageError } = require('../utils/errors')
5
5
  const { find_project_root } = require('../config/paths')
6
6
  const { EXIT_CODES } = require('../constants')
7
+ const { emit: emit_analytics } = require('../utils/analytics')
7
8
 
8
9
  const HELP_TEXT = `Usage: happyskills uninstall <owner/skill> [...] [options]
9
10
 
@@ -59,6 +60,7 @@ const run = (args) => catch_errors('Uninstall failed', async () => {
59
60
  failures.push({ skill, error: msg })
60
61
  } else {
61
62
  results.push({ skill, result })
63
+ emit_analytics('uninstall', {})
62
64
  }
63
65
  }
64
66
 
package/src/index.js CHANGED
@@ -134,6 +134,16 @@ const run = (argv) => {
134
134
  set_json_mode()
135
135
  }
136
136
 
137
+ // Spec 260521-03 § 8.4 — propagate intent envelope from --intent flag or
138
+ // the HAPPYSKILLS_INTENT_ENVELOPE env var (set by a parent process such
139
+ // as the MCP server). Initializing here makes the envelope visible to
140
+ // every subsequent API call within this CLI invocation.
141
+ if (args.flags.intent || process.env.HAPPYSKILLS_INTENT_ENVELOPE) {
142
+ const { init_from_flag, init_from_env } = require('./utils/intent')
143
+ if (args.flags.intent) init_from_flag(args.flags.intent)
144
+ else init_from_env()
145
+ }
146
+
137
147
  if (args.flags.version === true) {
138
148
  show_version()
139
149
  return process.exit(EXIT_CODES.SUCCESS)
@@ -0,0 +1,31 @@
1
+ // Spec 260521-03 § 7.3 — CLI client-emitted events.
2
+ //
3
+ // Fire-and-forget. Each event is its own POST to /events (no batching —
4
+ // CLI volume is low). A failure logs to stderr at debug level and never
5
+ // affects the user-visible flow. Lazy-loads the API client to keep startup
6
+ // fast.
7
+
8
+ const { error: { catch_errors } } = require('puffy-core')
9
+
10
+ const post_event = (event_type, metadata) => catch_errors('Failed to post analytics event', async () => {
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 || {},
16
+ client_ts: Date.now(),
17
+ }] }, { auth: true, unwrap: false })
18
+ })
19
+
20
+ // Caller-friendly wrapper. Errors are swallowed — engagement telemetry must
21
+ // never block the user.
22
+ const emit = (event_type, metadata) => {
23
+ // Don't return the promise — fire-and-forget. Errors stay quiet.
24
+ post_event(event_type, metadata).then(([errors]) => {
25
+ if (errors && process.env.HAPPYSKILLS_DEBUG) {
26
+ process.stderr.write(`[analytics] ${event_type} failed: ${errors[0]?.message || 'unknown'}\n`)
27
+ }
28
+ }).catch(() => {})
29
+ }
30
+
31
+ module.exports = { emit }
@@ -0,0 +1,55 @@
1
+ // Spec 260521-03 § 10.1, § 18.6 — First-launch attribution prompt.
2
+ //
3
+ // Shown once after the first successful `happyskills login` for a fresh
4
+ // install. Single keypress, skippable, no re-prompt.
5
+
6
+ const readline = require('readline')
7
+
8
+ const SOURCES = [
9
+ { key: '1', value: 'reddit', label: 'Reddit' },
10
+ { key: '2', value: 'hacker_news', label: 'Hacker News' },
11
+ { key: '3', value: 'twitter', label: 'Twitter / X' },
12
+ { key: '4', value: 'youtube', label: 'YouTube' },
13
+ { key: '5', value: 'blog', label: 'Blog or article' },
14
+ { key: '6', value: 'podcast', label: 'Podcast' },
15
+ { key: '7', value: 'friend', label: 'A friend / colleague' },
16
+ { key: '8', value: 'ai_search', label: 'Search (Google / ChatGPT / Claude / Perplexity)' },
17
+ { key: '9', value: 'other', label: 'Other' },
18
+ ]
19
+
20
+ const read_single_keypress = () => new Promise((resolve) => {
21
+ if (!process.stdin.isTTY) return resolve('')
22
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
23
+ process.stdin.setRawMode(true)
24
+ const on_data = (chunk) => {
25
+ const c = chunk.toString()
26
+ process.stdin.setRawMode(false)
27
+ process.stdin.removeListener('data', on_data)
28
+ rl.close()
29
+ // Ctrl-C → treat as skip but stop the process so callers don't continue.
30
+ if (c === '\x03') { process.stderr.write('\n'); process.exit(0) }
31
+ resolve(c)
32
+ }
33
+ process.stdin.on('data', on_data)
34
+ process.stdin.resume()
35
+ })
36
+
37
+ // Returns the chosen source value (one of SOURCES[].value), or null if
38
+ // the user skipped (Enter or non-TTY).
39
+ const prompt_for_source = async () => {
40
+ process.stderr.write('\n')
41
+ process.stderr.write(' One quick thing — what brought you here? Choose one (or press Enter to skip):\n')
42
+ for (const s of SOURCES) {
43
+ process.stderr.write(` ${s.key}) ${s.label}\n`)
44
+ }
45
+ process.stderr.write('\n > ')
46
+
47
+ const keypress = await read_single_keypress()
48
+ process.stderr.write('\n')
49
+
50
+ if (!keypress || keypress === '\n' || keypress === '\r') return null
51
+ const match = SOURCES.find(s => s.key === keypress)
52
+ return match ? match.value : null
53
+ }
54
+
55
+ module.exports = { prompt_for_source, SOURCES }
@@ -0,0 +1,63 @@
1
+ // Spec 260521-03 § 8.4 — Process-local intent envelope state.
2
+ //
3
+ // The CLI propagates the active envelope to subprocesses via the
4
+ // HAPPYSKILLS_INTENT_ENVELOPE env var or the --intent CLI flag. Within a
5
+ // single CLI process, the envelope lives in memory and is updated whenever
6
+ // the server sends back a refreshed value on the X-Intent-Envelope response
7
+ // header.
8
+
9
+ let _current = null
10
+
11
+ const ENV_VAR = 'HAPPYSKILLS_INTENT_ENVELOPE'
12
+
13
+ const init_from_env = () => {
14
+ if (_current) return _current
15
+ const from_env = process.env[ENV_VAR]
16
+ if (from_env) _current = from_env
17
+ return _current
18
+ }
19
+
20
+ const init_from_flag = (envelope) => {
21
+ if (envelope) _current = envelope
22
+ }
23
+
24
+ const get_envelope = () => init_from_env()
25
+
26
+ const set_envelope = (envelope) => {
27
+ if (envelope) {
28
+ _current = envelope
29
+ // Propagate to subprocesses spawned from this point on. We mutate
30
+ // process.env intentionally — child_process spawn() copies env at
31
+ // fork time, so setting it here makes the refreshed value visible
32
+ // to any subprocess started later in the same CLI invocation.
33
+ process.env[ENV_VAR] = envelope
34
+ }
35
+ }
36
+
37
+ const clear_envelope = () => {
38
+ _current = null
39
+ delete process.env[ENV_VAR]
40
+ }
41
+
42
+ // Spec 260521-03 addendum § 4 — Extract the intent_id UUID from the active
43
+ // envelope so the CLI can attach it to /telemetry/discovery beacon payloads.
44
+ // The envelope is `<base64url-payload>.<base64url-hmac>`; the CLI doesn't
45
+ // have the HMAC secret and doesn't need to verify — it just reads the
46
+ // payload portion. Returns null when no envelope is active or the payload
47
+ // can't be decoded.
48
+ const get_intent_id = () => {
49
+ const env = get_envelope()
50
+ if (!env) return null
51
+ try {
52
+ const dot = env.indexOf('.')
53
+ if (dot <= 0) return null
54
+ const body = env.slice(0, dot)
55
+ const json = Buffer.from(body, 'base64url').toString('utf-8')
56
+ const payload = JSON.parse(json)
57
+ return payload.intent_id || null
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ module.exports = { init_from_env, init_from_flag, get_envelope, set_envelope, clear_envelope, get_intent_id, ENV_VAR }