happyskills 0.52.0 → 0.53.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.53.0] - 2026-05-25
11
+
12
+ ### Added
13
+
14
+ - **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.
15
+ - **`auth.cli_first_link` event** emitted alongside the attribution prompt — the principal-to-operator handoff signal for the time-to-CLI-link metric.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+ - **`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.
20
+
21
+ ## [0.52.1] - 2026-05-25
22
+
23
+ ### Fixed
24
+
25
+ - **`release` now validates kits against the kit contract (README.md) instead of the skill contract** (`cli/src/commands/release.js`). `run_validation()` was calling `validate_skill_md(dir, basename, null)` — hard-coding the third argument to `null` instead of reading the `type` from `skill.json`. For kit repos this meant the skill branch fired even though the kit had no `SKILL.md`, so `release` failed with `"SKILL.md not found"` immediately after CLI v0.52.0 moved kits to `README.md`. Discovered while migrating `nicolasdao/_kit-doc-essentials` from the old to the new format. Now reads `manifest.type` from `skill.json` first and passes it through to both `validate_skill_md` and `validate_cross`. The standalone `validate` command was unaffected — it was already wiring `skill_type` through correctly.
26
+
10
27
  ## [0.52.0] - 2026-05-25
11
28
 
12
29
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.52.0",
3
+ "version": "0.53.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()
@@ -122,11 +122,14 @@ const compute_bump = (base_version, bump) => {
122
122
  }
123
123
 
124
124
  const run_validation = (dir, skill_name) => catch_errors('Validation failed', async () => {
125
- const [md_err, md_data] = await validate_skill_md(dir, path.basename(dir), null)
126
- if (md_err) throw md_err
125
+ // Read skill.json first so we know whether to validate against the skill
126
+ // (SKILL.md + frontmatter) or kit (README.md, no frontmatter) contract.
127
127
  const [json_err, json_data] = await validate_skill_json(dir)
128
128
  if (json_err) throw json_err
129
- const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, json_data.manifest?.type)
129
+ const skill_type = json_data.manifest?.type
130
+ const [md_err, md_data] = await validate_skill_md(dir, path.basename(dir), skill_type)
131
+ if (md_err) throw md_err
132
+ const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, skill_type)
130
133
  if (cross_err) throw cross_err
131
134
  const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
132
135
  if (marker_err) throw marker_err
@@ -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,42 @@
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
+ module.exports = { init_from_env, init_from_flag, get_envelope, set_envelope, clear_envelope, ENV_VAR }