happyskills 1.7.1 → 1.9.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,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.9.0] - 2026-06-06
11
+
12
+ ### Fixed
13
+
14
+ - Fix first-time installs leaving a skill unlinked and invisible. When no agent was configured or detected (empty project, fresh machine, no `--agents` / `HAPPYSKILLS_AGENTS` / config / `~/.<agent>/skills/`), `install` wrote files to `.agents/skills/` but created no agent symlink and said nothing — the skill was installed yet invisible to every agent runtime. Agent resolution now falls back to Claude Code when nothing else is detected, links the skill, and surfaces a notice in both human output and the `--json` `warnings[]`. Target a different agent with `--agents` or `happyskills agents add <agent>`.
15
+ - Stop telling signed-in users they are "not logged in." When a private skill or dependency was inaccessible, the CLI showed an authentication error pointing to `happyskills login` even when the user was already signed in — the real issue was missing access. The `install` `access_denied` skipped-dependency warning and every 401-derived error are now session-state aware: a signed-in user is told their account lacks access and to ask the owner to grant it, while a genuinely signed-out user is prompted to sign in. Also corrects the `check`, `publish`, and `whoami` hints.
16
+
17
+ ## [1.8.0] - 2026-06-05
18
+
19
+ ### Changed
20
+
21
+ - 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).
22
+
10
23
  ## [1.7.1] - 2026-06-04
11
24
 
12
25
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.7.1",
3
+ "version": "1.9.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)",
@@ -116,8 +116,19 @@ const resolve_agents = (agents_flag, options = {}) => catch_errors('Agent resolu
116
116
  // 5. Home-physical fallback (legacy auto-detect)
117
117
  const [errors, home_detected] = await detect_agents({ global: true })
118
118
  if (errors) throw errors[0]
119
+ if (home_detected.length > 0) {
120
+ return { agents: home_detected, source: 'home' }
121
+ }
119
122
 
120
- return { agents: home_detected, source: 'home' }
123
+ // 6. Default nothing was configured or detected anywhere (the first-run
124
+ // case: empty project, fresh machine, no flag/env/config). Returning an
125
+ // empty list here is the trap: physical files would land in .agents/skills/
126
+ // but no agent symlink is created, so the skill is installed yet invisible
127
+ // to every agent runtime. Fall back to Claude Code — the primary agent —
128
+ // and tag the source as 'default' so callers can tell the user a default
129
+ // was chosen and how to target a different agent. resolve_agents never
130
+ // returns an empty agent list.
131
+ return { agents: [get_agent('claude')], source: 'default' }
121
132
  })
122
133
 
123
134
  module.exports = { detect_agents, resolve_agents }
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+ const { describe, it, beforeEach, afterEach } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const os = require('os')
7
+
8
+ const { resolve_agents } = require('./detector')
9
+
10
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-detector-test-'))
11
+ const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
12
+
13
+ describe('resolve_agents', () => {
14
+ let tmp
15
+ let saved_env
16
+
17
+ beforeEach(() => {
18
+ tmp = make_tmp()
19
+ // Isolate from the host's HAPPYSKILLS_AGENTS preference.
20
+ saved_env = process.env.HAPPYSKILLS_AGENTS
21
+ delete process.env.HAPPYSKILLS_AGENTS
22
+ })
23
+ afterEach(() => {
24
+ cleanup(tmp)
25
+ if (saved_env === undefined) delete process.env.HAPPYSKILLS_AGENTS
26
+ else process.env.HAPPYSKILLS_AGENTS = saved_env
27
+ })
28
+
29
+ it('honors the --agents flag (source: flag)', async () => {
30
+ const [errors, result] = await resolve_agents('claude,cursor', { project_root: tmp })
31
+ assert.equal(errors, null)
32
+ assert.equal(result.source, 'flag')
33
+ assert.deepEqual(result.agents.map(a => a.id), ['claude', 'cursor'])
34
+ })
35
+
36
+ it('reads HAPPYSKILLS_AGENTS when no flag is passed (source: env)', async () => {
37
+ process.env.HAPPYSKILLS_AGENTS = 'cursor'
38
+ const [errors, result] = await resolve_agents(undefined, { project_root: tmp })
39
+ assert.equal(errors, null)
40
+ assert.equal(result.source, 'env')
41
+ assert.deepEqual(result.agents.map(a => a.id), ['cursor'])
42
+ })
43
+
44
+ // The first-run trap regression guard: with nothing configured or detected,
45
+ // resolve_agents must NEVER return an empty agent list. An empty list is what
46
+ // left skills installed-but-invisible (no .claude/skills symlink) for new
47
+ // users. The fallback is Claude Code, tagged source: 'default'.
48
+ it('never returns an empty agent list — falls back to Claude Code', async () => {
49
+ const [errors, result] = await resolve_agents(undefined, { project_root: tmp })
50
+ assert.equal(errors, null)
51
+ assert.ok(result.agents.length >= 1, 'resolve_agents must return at least one agent')
52
+ // On a clean environment (no project/home agent dirs, no config) this is
53
+ // the default branch. On a dev machine an agent may be detected via home
54
+ // or config — in that case the source differs but the never-empty
55
+ // invariant above still holds.
56
+ if (result.source === 'default') {
57
+ assert.deepEqual(result.agents.map(a => a.id), ['claude'])
58
+ }
59
+ })
60
+ })
package/src/api/client.js CHANGED
@@ -27,10 +27,12 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
27
27
  headers['X-Intent-Envelope'] = active_envelope
28
28
  }
29
29
 
30
+ let token_attached = false
30
31
  if (auth) {
31
32
  const [, token_data] = await load_token()
32
33
  if (token_data) {
33
34
  headers['Authorization'] = `Bearer ${token_data.id_token}`
35
+ token_attached = true
34
36
  }
35
37
  }
36
38
 
@@ -83,7 +85,15 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
83
85
  const err_details = data?.error?.details
84
86
 
85
87
  if (res.status === 401) {
86
- const err = new AuthError(err_msg)
88
+ // The API answers 401 for a private resource the caller can't see —
89
+ // which happens BOTH when nobody is signed in AND when a signed-in
90
+ // user simply lacks access (private repos return 401/404 so their
91
+ // existence isn't leaked). Telling a signed-in user to "log in" is
92
+ // wrong, so the message depends on whether we actually sent a token.
93
+ const message = token_attached
94
+ ? 'Access denied. Your account does not have access to this private resource, or your session has expired. If you are already signed in, ask the owner to grant you access; otherwise run `happyskills login`.'
95
+ : 'You are not signed in. Run `happyskills login` to access private skills.'
96
+ const err = new AuthError(message)
87
97
  if (err_details) err.details = err_details
88
98
  throw err
89
99
  }
@@ -10,7 +10,11 @@
10
10
  const { describe, it } = require('node:test')
11
11
  const assert = require('node:assert/strict')
12
12
  const crypto = require('node:crypto')
13
+ const os = require('node:os')
14
+ const path = require('node:path')
15
+ const fs = require('node:fs')
13
16
  const client = require('./client')
17
+ const { save_token } = require('../auth/token_store')
14
18
 
15
19
  const EMPTY_SHA = crypto.createHash('sha256').update('').digest('hex')
16
20
 
@@ -51,3 +55,70 @@ describe('api client — x-amz-content-sha256 (CloudFront OAC SigV4 payload hash
51
55
  assert.equal(c.headers['x-amz-content-sha256'], undefined)
52
56
  })
53
57
  })
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────────
60
+ // 401 messaging is login-state aware. The API returns 401 for a private resource
61
+ // the caller can't see — which happens BOTH when nobody is signed in AND when a
62
+ // signed-in user simply lacks access. The CLI must never tell a signed-in user
63
+ // to log in. token_store reads XDG_CONFIG_HOME lazily, so we point it at a temp
64
+ // dir to control whether a token gets attached.
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+
67
+ // Stub fetch to return a 401 envelope regardless of input.
68
+ const with_401 = async (fn) => {
69
+ const orig = global.fetch
70
+ global.fetch = async () => ({
71
+ ok: false,
72
+ status: 401,
73
+ headers: { get: () => null },
74
+ json: async () => ({ error: { code: 'AUTH_REQUIRED', message: 'Unauthorized' } }),
75
+ })
76
+ try { return await fn() } finally { global.fetch = orig }
77
+ }
78
+
79
+ const with_tmp_config = async (fn) => {
80
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hs-client-401-'))
81
+ const orig = process.env.XDG_CONFIG_HOME
82
+ process.env.XDG_CONFIG_HOME = dir
83
+ try { await fn(dir) } finally {
84
+ if (orig !== undefined) process.env.XDG_CONFIG_HOME = orig
85
+ else delete process.env.XDG_CONFIG_HOME
86
+ fs.rmSync(dir, { recursive: true, force: true })
87
+ }
88
+ }
89
+
90
+ describe('api client — 401 message never tells a signed-in user to log in', () => {
91
+ // client.request is wrapped in puffy-core catch_errors, so it RETURNS
92
+ // [errors, result] rather than throwing. Pull the AuthError out of the chain.
93
+ const auth_error_from = (errors) => {
94
+ assert.ok(errors, 'expected an error tuple')
95
+ const err = errors.find(e => e && e.name === 'AuthError')
96
+ assert.ok(err, 'expected an AuthError in the error chain')
97
+ return err
98
+ }
99
+
100
+ it('signed-in 401 → access-denied wording, NOT "log in"', async () => {
101
+ await with_tmp_config(async () => {
102
+ // A valid (non-expired) token → an Authorization header is attached.
103
+ await save_token({ id_token: 'a.b.c', access_token: 'x', refresh_token: 'r', expires_in: 3600 })
104
+ await with_401(async () => {
105
+ const [errors] = await client.get('/repos/acme/private-skill')
106
+ const err = auth_error_from(errors)
107
+ assert.match(err.message, /access denied|does not have access/i)
108
+ assert.doesNotMatch(err.message, /you are not signed in/i)
109
+ })
110
+ })
111
+ })
112
+
113
+ it('not-signed-in 401 → tells the user to sign in', async () => {
114
+ await with_tmp_config(async () => {
115
+ // No token saved → no Authorization header attached.
116
+ await with_401(async () => {
117
+ const [errors] = await client.get('/repos/acme/private-skill')
118
+ const err = auth_error_from(errors)
119
+ assert.match(err.message, /not signed in/i)
120
+ assert.match(err.message, /happyskills login/)
121
+ })
122
+ })
123
+ })
124
+ })
@@ -42,7 +42,7 @@ const render_identity_text = (identity) => {
42
42
  if (identity.workspace_error) {
43
43
  console.log()
44
44
  print_warn(`Failed to list workspaces: ${identity.workspace_error}`)
45
- print_hint('Check your authentication with happyskills login')
45
+ print_hint('This is usually a temporary connection issue — try again in a moment.')
46
46
  } else if (identity.workspaces && identity.workspaces.length > 0) {
47
47
  console.log()
48
48
  print_label('Workspaces', '')
@@ -201,8 +201,8 @@ const run = (args) => catch_errors('Check failed', async () => {
201
201
  }
202
202
  if (no_access.length > 0) {
203
203
  console.log()
204
- print_warn('Some skills require authentication.')
205
- print_hint(`Run ${code('happyskills login')} to refresh your session.`)
204
+ print_warn(`${no_access.length} installed skill(s) are private and your account can't access them.`)
205
+ print_hint(`If you're signed in, ask the owner to grant you access. If not, run ${code('happyskills login')}.`)
206
206
  }
207
207
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
208
208
 
@@ -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
 
@@ -73,7 +73,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
73
73
 
74
74
  // Verify auth early — fail fast before validation and upload
75
75
  const [ws_err, workspaces] = await workspaces_api.list_workspaces()
76
- if (ws_err) throw e('Authentication failed — run \'happyskills login\' to re-authenticate.', ws_err)
76
+ if (ws_err) throw e('Could not load your workspaces before publishing check your connection, or run `happyskills login` if your session has expired.', ws_err)
77
77
 
78
78
  const [dir_err, dir] = await resolve_skill_dir(skill_name)
79
79
  if (dir_err) throw e(`Skill "${skill_name}" not found`, dir_err)
@@ -14,14 +14,47 @@ const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs'
14
14
  const { SKILL_JSON, SKILL_TYPES } = require('../constants')
15
15
  const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('../agents')
16
16
  const { is_skill_enabled } = require('../agents/status')
17
+ const { load_token } = require('../auth/token_store')
17
18
  const { create_spinner } = require('../ui/spinner')
18
19
  const { print_success, print_warn, print_info } = require('../ui/output')
20
+ const { emit_batch } = require('../utils/analytics')
21
+ const { CLI_VERSION } = require('../constants')
19
22
 
20
23
  const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
21
24
  const hint = dep.install_hint ? ` (install: ${dep.install_hint})` : ''
22
25
  return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
23
26
  })
24
27
 
28
+ // Build the warning shown when private dependencies are skipped for lack of
29
+ // access. The server returns these because the skill is private and the caller
30
+ // can't see it — which is NOT the same as "not logged in". A signed-in user is
31
+ // most often simply missing access, so the wording is keyed on `logged_in`:
32
+ // we never tell a logged-in user to log in. Pure + exported so the wording is
33
+ // unit-testable. Returns { warn_lines: string[], hint: string }.
34
+ const format_access_denied_lines = (access_denied, logged_in) => {
35
+ const noun = access_denied.length === 1 ? 'dependency' : 'dependencies'
36
+ const them = access_denied.length === 1 ? 'it' : 'them'
37
+ const head = logged_in
38
+ ? `${access_denied.length} private ${noun} skipped — your account doesn't have access:`
39
+ : `${access_denied.length} private ${noun} skipped — you're not signed in:`
40
+ const warn_lines = [head, ...access_denied.map(s => ` ${s.skill} (required by ${s.required_by})`)]
41
+ const hint = logged_in
42
+ ? `Ask the owner to grant you access to ${them}, then re-run install.`
43
+ : `Sign in, then re-run install: happyskills login`
44
+ return { warn_lines, hint }
45
+ }
46
+
47
+ // First-run transparency. When agent resolution falls all the way through to the
48
+ // Claude Code default (nothing was configured or detected — empty project, fresh
49
+ // machine, no flag/env/config), we must tell the user a default was chosen rather
50
+ // than silently linking. Returns the machine-readable warning for the --json
51
+ // `warnings[]`; empty unless the default actually drove a link (linked_count > 0),
52
+ // so kit-only installs (nothing agent-invocable to link) stay quiet.
53
+ const _format_default_agent_warnings = (agents_source, linked_count) =>
54
+ (agents_source === 'default' && linked_count > 0)
55
+ ? ['No agent was configured; linked to Claude Code by default. Use --agents or `happyskills agents add <agent>` to target a different agent.']
56
+ : []
57
+
25
58
  // Circular dependencies the resolver broke automatically. The install is safe and terminates,
26
59
  // but the author should remove one edge — so we surface a warning rather than failing silently.
27
60
  const _format_cycle_warnings = (cycles) => (cycles || []).map(({ from, to }) =>
@@ -64,7 +97,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
64
97
  // Resolve target agents
65
98
  const [agents_err, agents_result] = await resolve_agents(agents_flag, { global: is_global, project_root })
66
99
  if (agents_err) throw e('Agent resolution failed', agents_err)
67
- const { agents } = agents_result
100
+ const { agents, source: agents_source } = agents_result
68
101
  const temp_dir = tmp_dir(base_dir)
69
102
  const lock_dir = lock_root(is_global, project_root)
70
103
 
@@ -316,11 +349,36 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
316
349
  }
317
350
  }
318
351
 
352
+ // Cache-immune install counting (spec 260604-01). Fire one
353
+ // install.completed beacon per newly-installed skill — root AND each
354
+ // transitive dependency — batched into a single POST /events. This is the
355
+ // source of truth for install/download counts: it replaces the clone-
356
+ // endpoint side-effect that CloudFront cache HITs silently dropped. Only
357
+ // reached on a genuine install (no_op paths returned earlier and emit
358
+ // nothing). Fire-and-forget — never blocks or fails the install.
359
+ emit_batch(downloaded.map(({ pkg }) => {
360
+ const requested_by = updates[pkg.skill]?.requested_by || []
361
+ return {
362
+ event_type: 'install.completed',
363
+ repo_id: pkg.repo_id || null,
364
+ metadata: {
365
+ version: pkg.version,
366
+ dependency: !requested_by.includes('__root__'),
367
+ cli_version: CLI_VERSION,
368
+ },
369
+ }
370
+ }))
371
+
319
372
  spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
320
373
 
321
374
  const linked_count = downloaded.length - disabled_skills.size
322
375
  if (agents.length > 0 && linked_count > 0) {
323
- print_info(`Linked to: ${agents.map(a => a.display_name).join(', ')}`)
376
+ if (agents_source === 'default') {
377
+ print_warn('No agent was configured — linked to Claude Code by default.')
378
+ print_info('Target a different agent with --agents (e.g. --agents cursor) or run: happyskills agents add cursor')
379
+ } else {
380
+ print_info(`Linked to: ${agents.map(a => a.display_name).join(', ')}`)
381
+ }
324
382
  }
325
383
 
326
384
  const [, missing_deps] = await check_system_dependencies(packages)
@@ -339,11 +397,12 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
339
397
  const not_found = skipped_deps.filter(s => s.reason === 'not_found')
340
398
  const other = skipped_deps.filter(s => s.reason !== 'access_denied' && s.reason !== 'not_found')
341
399
  if (access_denied.length > 0) {
342
- print_warn(`${access_denied.length} dependenc${access_denied.length === 1 ? 'y' : 'ies'} skipped (private, requires login):`)
343
- for (const s of access_denied) {
344
- print_warn(` ${s.skill} (required by ${s.required_by})`)
345
- }
346
- print_info(`Log in to install: happyskills login`)
400
+ // Detect the session state so we never tell a logged-in user to
401
+ // log in (see format_access_denied_lines for the rationale).
402
+ const [, token_data] = await load_token()
403
+ const { warn_lines, hint } = format_access_denied_lines(access_denied, !!token_data)
404
+ for (const line of warn_lines) print_warn(line)
405
+ print_info(hint)
347
406
  }
348
407
  if (not_found.length > 0) {
349
408
  print_warn(`${not_found.length} dependenc${not_found.length === 1 ? 'y' : 'ies'} skipped (not found in registry):`)
@@ -364,7 +423,8 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
364
423
  const installed_set = new Set(packages_to_install.map(p => p.skill))
365
424
  const installed = packages_to_install.map(p => ({ skill: p.skill, version: p.version }))
366
425
  const skipped = packages.filter(p => !installed_set.has(p.skill)).map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
367
- const warnings = [..._format_warnings(missing_deps), ...cycle_warnings]
426
+ const default_agent_warnings = _format_default_agent_warnings(agents_source, linked_count)
427
+ const warnings = [..._format_warnings(missing_deps), ...cycle_warnings, ...default_agent_warnings]
368
428
  const forced = forced_pkgs.map(p => ({ skill: p.skill, version: p.version }))
369
429
 
370
430
  const linked_agents = agents.map(a => a.id)
@@ -424,4 +484,4 @@ const install_from_lock = (lock_data, options = {}) => catch_errors('Install fro
424
484
  return { source: 'skills-lock.json', installed: all_installed, skipped: all_skipped, warnings: all_warnings }
425
485
  })
426
486
 
427
- module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by }
487
+ module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by, format_access_denied_lines, _format_default_agent_warnings }
@@ -1,6 +1,6 @@
1
1
  const { describe, it } = require('node:test')
2
2
  const assert = require('node:assert')
3
- const { merge_requested_by } = require('./installer')
3
+ const { merge_requested_by, format_access_denied_lines, _format_default_agent_warnings } = require('./installer')
4
4
 
5
5
  describe('merge_requested_by', () => {
6
6
  it('records the root skill for a fresh dependency', () => {
@@ -39,3 +39,63 @@ describe('merge_requested_by', () => {
39
39
  assert.deepStrictEqual(merge_requested_by([], 'a/dep', 'a/root'), ['a/root'])
40
40
  })
41
41
  })
42
+
43
+ describe('format_access_denied_lines (skipped private-dependency wording)', () => {
44
+ const one = [{ skill: 'acme/secret', required_by: 'acme/_kit-x' }]
45
+ const two = [
46
+ { skill: 'acme/secret', required_by: 'acme/_kit-x' },
47
+ { skill: 'acme/other', required_by: 'acme/_kit-x' },
48
+ ]
49
+
50
+ it('signed-in: blames access (not auth) and points to the owner — never "log in"', () => {
51
+ const { warn_lines, hint } = format_access_denied_lines(one, true)
52
+ // The reported bug: a logged-in user was told they were not logged in.
53
+ assert.match(warn_lines[0], /your account doesn't have access/)
54
+ assert.doesNotMatch(warn_lines[0], /not signed in|log ?in/i)
55
+ assert.match(hint, /ask the owner/i)
56
+ assert.doesNotMatch(hint, /happyskills login/)
57
+ // The offending skill is still listed for the user.
58
+ assert.match(warn_lines[1], /acme\/secret \(required by acme\/_kit-x\)/)
59
+ })
60
+
61
+ it('signed-out: says not signed in and points to login', () => {
62
+ const { warn_lines, hint } = format_access_denied_lines(one, false)
63
+ assert.match(warn_lines[0], /you're not signed in/)
64
+ assert.match(hint, /happyskills login/)
65
+ assert.doesNotMatch(hint, /ask the owner/i)
66
+ })
67
+
68
+ it('pluralizes the count and noun', () => {
69
+ assert.match(format_access_denied_lines(one, true).warn_lines[0], /^1 private dependency /)
70
+ assert.match(format_access_denied_lines(two, true).warn_lines[0], /^2 private dependencies /)
71
+ // One warn line per skipped dependency, plus the header.
72
+ assert.equal(format_access_denied_lines(two, true).warn_lines.length, 3)
73
+ })
74
+ })
75
+
76
+ describe('_format_default_agent_warnings (first-run default-agent transparency)', () => {
77
+ // Regression guard for the first-run trap: when nothing is configured or
78
+ // detected, resolve_agents falls back to Claude Code (source: 'default').
79
+ // The install must NOT be silent about that — it surfaces a warning so the
80
+ // human is told and an operating agent can report it. Before the fix the
81
+ // install completed with zero agent links and zero feedback.
82
+ it('emits a warning when the Claude default drove a link', () => {
83
+ const w = _format_default_agent_warnings('default', 1)
84
+ assert.equal(w.length, 1)
85
+ assert.match(w[0], /linked to Claude Code by default/)
86
+ // Tells the user how to target a different agent (no silent magic).
87
+ assert.match(w[0], /--agents|happyskills agents add/)
88
+ })
89
+
90
+ it('stays silent when an agent was actually configured/detected', () => {
91
+ assert.deepEqual(_format_default_agent_warnings('flag', 1), [])
92
+ assert.deepEqual(_format_default_agent_warnings('env', 2), [])
93
+ assert.deepEqual(_format_default_agent_warnings('project', 1), [])
94
+ assert.deepEqual(_format_default_agent_warnings('config', 1), [])
95
+ assert.deepEqual(_format_default_agent_warnings('home', 3), [])
96
+ })
97
+
98
+ it('stays silent when the default fired but nothing was linked (e.g. kit-only install)', () => {
99
+ assert.deepEqual(_format_default_agent_warnings('default', 0), [])
100
+ })
101
+ })
@@ -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
+ })