happyskills 1.4.0 → 1.5.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,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - Add `stats` command for read-only consumption stats over a bounded time window. Two scopes: `--scope my_activity` (your own installs / updates / searches / uninstalls) and `--scope my_skills_reach` (aggregate reach of skills you authored — never identifies *who* consumed them). Flags: `--metric`, `--period <7d|30d|90d|6mo|12mo>` or `--from`/`--to`, `--group-by`, `--skill <owner/skill>` (`my_skills_reach` only; resolved to its id server-side), and `--json`. Requires auth; calls `POST /me/stats:query` (API v5.3.0+). Surfaced in `happyskills schema`.
15
+
16
+ ## [1.4.1] - 2026-06-03
17
+
18
+ ### Fixed
19
+ - Send the `x-amz-content-sha256` payload-hash header on bodyless POST/DELETE requests so CloudFront OAC SigV4 signing accepts them. It was previously set only when a request had a body, so bodyless authenticated mutations (`star`, `unstar`, `delete`, `people remove`, `groups delete`, `access revoke`) were rejected by the Lambda Function URL with a 403 signature mismatch.
20
+
10
21
  ## [1.4.0] - 2026-06-03
11
22
 
12
23
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.4.0",
3
+ "version": "1.5.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
@@ -40,8 +40,15 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
40
40
  headers['Content-Type'] = 'application/json'
41
41
  }
42
42
 
43
- if (body_str) {
44
- headers['x-amz-content-sha256'] = createHash('sha256').update(body_str).digest('hex')
43
+ // CloudFront OAC signs requests to the Lambda Function URL with SigV4, which
44
+ // covers the payload hash via x-amz-content-sha256. GET/HEAD carry no body and
45
+ // OAC uses the empty-string hash deterministically, so they work without it.
46
+ // But body-carrying methods (POST/PUT/PATCH/DELETE) MUST send the header or the
47
+ // Function URL rejects the signature with a 403 — and that applies even when the
48
+ // specific request has no body (e.g. `star`/`unstar`, `delete`, `people remove`).
49
+ // The correct hash for an empty body is sha256(''). See docs/gotchas/deployment.md § 2.1.
50
+ if (method !== 'GET' && method !== 'HEAD') {
51
+ headers['x-amz-content-sha256'] = createHash('sha256').update(body_str || '').digest('hex')
45
52
  }
46
53
 
47
54
  let res
@@ -0,0 +1,53 @@
1
+ // Run with: node --test src/api/client.test.js
2
+ //
3
+ // Pins the x-amz-content-sha256 payload-hash header that CloudFront OAC requires
4
+ // to SigV4-sign requests to the Lambda Function URL. The original bug: the header
5
+ // was set only when a body existed, so bodyless authed mutations (star/unstar,
6
+ // delete, people remove, ...) reached the Function URL without it and were
7
+ // rejected with a 403 signature mismatch. GET/HEAD are exempt — OAC uses the
8
+ // deterministic empty-string hash for them. See docs/gotchas/deployment.md § 2.1.
9
+
10
+ const { describe, it } = require('node:test')
11
+ const assert = require('node:assert/strict')
12
+ const crypto = require('node:crypto')
13
+ const client = require('./client')
14
+
15
+ const EMPTY_SHA = crypto.createHash('sha256').update('').digest('hex')
16
+
17
+ // Run a client call with global.fetch stubbed to capture the outgoing request.
18
+ const capture_request = async (fn) => {
19
+ const orig = global.fetch
20
+ const captured = {}
21
+ global.fetch = async (url, opts) => {
22
+ captured.url = url
23
+ captured.method = opts.method
24
+ captured.headers = opts.headers
25
+ captured.body = opts.body
26
+ return { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ data: {} }) }
27
+ }
28
+ try { await fn() } finally { global.fetch = orig }
29
+ return captured
30
+ }
31
+
32
+ describe('api client — x-amz-content-sha256 (CloudFront OAC SigV4 payload hash)', () => {
33
+ it('bodyless POST sends the empty-string payload hash (regression: star → 403)', async () => {
34
+ const c = await capture_request(() => client.post('/repos/acme/deploy-aws/star', undefined, { auth: false }))
35
+ assert.equal(c.headers['x-amz-content-sha256'], EMPTY_SHA)
36
+ })
37
+
38
+ it('bodyless DELETE sends the empty-string payload hash (regression: unstar/delete → 403)', async () => {
39
+ const c = await capture_request(() => client.del('/repos/acme/deploy-aws/star', { auth: false }))
40
+ assert.equal(c.headers['x-amz-content-sha256'], EMPTY_SHA)
41
+ })
42
+
43
+ it('POST with a body sends the body hash (unchanged)', async () => {
44
+ const c = await capture_request(() => client.post('/repos:search', { q: 'x' }, { auth: false }))
45
+ const expected = crypto.createHash('sha256').update(JSON.stringify({ q: 'x' })).digest('hex')
46
+ assert.equal(c.headers['x-amz-content-sha256'], expected)
47
+ })
48
+
49
+ it('GET does not send the header (OAC uses the deterministic empty hash)', async () => {
50
+ const c = await capture_request(() => client.get('/repos/acme/deploy-aws', { auth: false }))
51
+ assert.equal(c.headers['x-amz-content-sha256'], undefined)
52
+ })
53
+ })
@@ -58,6 +58,7 @@ const AUDIENCE_HINT = {
58
58
  diff: 'consumer', pull: 'consumer', snapshot: 'consumer',
59
59
  reconcile: 'consumer', enable: 'consumer', disable: 'consumer',
60
60
  versions: 'consumer', changelog: 'consumer', bump: 'consumer',
61
+ stats: 'consumer',
61
62
  // author
62
63
  publish: 'author', convert: 'author', fork: 'author',
63
64
  validate: 'author', delete: 'author', visibility: 'author',
@@ -0,0 +1,199 @@
1
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
+ const client = require('../api/client')
3
+ const { print_help, print_table, print_json, print_info, print_warn, print_label } = require('../ui/output')
4
+ const { bold, dim, cyan, gray } = require('../ui/colors')
5
+ const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
6
+ const { EXIT_CODES } = require('../constants')
7
+ const token_store = require('../auth/token_store')
8
+
9
+ // happyskills stats (spec 260603-03) — authenticated, read-only consumption
10
+ // stats. Flags map 1:1 onto the closed API grammar (POST /me/stats:query); the
11
+ // server validates and is the source of truth. Two scopes:
12
+ // my_activity — your own actions (installs/updates/searches/uninstalls).
13
+ // my_skills_reach — how OTHER people consume skills you authored
14
+ // (aggregate-only, never names a consumer).
15
+
16
+ const METRICS_BY_SCOPE = {
17
+ my_activity: ['installs', 'updates', 'searches', 'uninstalls'],
18
+ my_skills_reach: ['installs_by_others', 'distinct_installers'],
19
+ }
20
+ const GROUP_BY_BY_SCOPE = {
21
+ my_activity: ['none', 'day', 'week', 'month', 'surface'],
22
+ my_skills_reach: ['none', 'day', 'week', 'month', 'skill'],
23
+ }
24
+ const VALID_PRESETS = ['7d', '30d', '90d', '6mo', '12mo']
25
+
26
+ const HELP_TEXT = `Usage: happyskills stats --scope <scope> --metric <metric> [options]
27
+
28
+ Retrieve your HappySkills usage over a bounded time window. Requires login.
29
+
30
+ Two scopes:
31
+ my_activity Your own actions: installs, updates, searches, uninstalls.
32
+ my_skills_reach How others consume skills you authored (aggregate only —
33
+ never identifies who installed).
34
+
35
+ Options:
36
+ --scope <scope> my_activity | my_skills_reach (required)
37
+ --metric <metric> my_activity: installs, updates, searches, uninstalls
38
+ my_skills_reach: installs_by_others, distinct_installers
39
+ --period <preset> ${VALID_PRESETS.join(' | ')}
40
+ --from <ISO date> Explicit window start (use instead of --period)
41
+ --to <ISO date> Explicit window end (default: now)
42
+ --group-by <bucket> my_activity: none, day, week, month, surface
43
+ my_skills_reach: none, day, week, month, skill
44
+ (default: none)
45
+ --skill <owner/skill> Limit my_skills_reach to one skill you authored
46
+ --json Output as JSON envelope
47
+
48
+ Examples:
49
+ happyskills stats --scope my_activity --metric installs --period 30d
50
+ happyskills stats --scope my_activity --metric searches --period 90d --group-by week --json
51
+ happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo
52
+ happyskills stats --scope my_skills_reach --metric installs_by_others --period 12mo --group-by skill
53
+ happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --skill acme/deploy-aws
54
+ happyskills stats --scope my_activity --metric installs --from 2026-05-01 --to 2026-06-01`
55
+
56
+ // Validate flags against the closed grammar and build the request body. Throws
57
+ // UsageError on bad input (server re-validates regardless). Returns the body.
58
+ const build_request = (flags) => {
59
+ const scope = flags.scope
60
+ if (!scope || !METRICS_BY_SCOPE[scope])
61
+ throw new UsageError('--scope is required and must be one of: my_activity, my_skills_reach')
62
+
63
+ const metric = flags.metric
64
+ if (!metric || !METRICS_BY_SCOPE[scope].includes(metric))
65
+ throw new UsageError(`--metric for ${scope} must be one of: ${METRICS_BY_SCOPE[scope].join(', ')}`)
66
+
67
+ const group_by = flags['group-by'] || 'none'
68
+ if (!GROUP_BY_BY_SCOPE[scope].includes(group_by))
69
+ throw new UsageError(`--group-by for ${scope} must be one of: ${GROUP_BY_BY_SCOPE[scope].join(', ')}`)
70
+
71
+ const has_preset = flags.period != null && flags.period !== true
72
+ const has_from = flags.from != null && flags.from !== true
73
+ if (has_preset && has_from)
74
+ throw new UsageError('Use either --period or --from/--to, not both.')
75
+ if (!has_preset && !has_from)
76
+ throw new UsageError(`A time window is required: --period <${VALID_PRESETS.join('|')}> or --from <date>.`)
77
+
78
+ let period
79
+ if (has_preset) {
80
+ if (!VALID_PRESETS.includes(flags.period))
81
+ throw new UsageError(`--period must be one of: ${VALID_PRESETS.join(', ')}`)
82
+ period = { preset: flags.period }
83
+ } else {
84
+ period = { from: flags.from }
85
+ if (flags.to != null && flags.to !== true) period.to = flags.to
86
+ }
87
+
88
+ const body = { scope, metric, group_by, period }
89
+
90
+ // --skill narrows my_skills_reach to a single authored skill. The server
91
+ // resolves the owner/skill slug to its id against your owned set.
92
+ const has_skill = flags.skill != null && flags.skill !== true
93
+ if (has_skill) {
94
+ if (scope !== 'my_skills_reach')
95
+ throw new UsageError('--skill is only valid for --scope my_skills_reach.')
96
+ if (!flags.skill.includes('/'))
97
+ throw new UsageError('--skill must be in owner/skill form, e.g. acme/deploy-aws.')
98
+ body.repo_slug = flags.skill
99
+ }
100
+
101
+ return body
102
+ }
103
+
104
+ const render_human = (data, warnings) => {
105
+ const window = data.period && data.period.from
106
+ ? `${String(data.period.from).slice(0, 10)} → ${String(data.period.to).slice(0, 10)}`
107
+ : ''
108
+ console.log(`\n${bold(`${data.scope} · ${data.metric}`)}${window ? dim(` ${window}`) : ''}\n`)
109
+ print_label(' Total', cyan(String(data.total)))
110
+
111
+ if (Array.isArray(data.series) && data.series.length > 0) {
112
+ console.log('')
113
+ const rows = data.series.map(p => [String(p.key), String(p.value)])
114
+ print_table(['Bucket', 'Count'], rows)
115
+ } else if (data.total === 0) {
116
+ print_info('No events in this window.')
117
+ }
118
+
119
+ if (data.period && data.period.available_from) {
120
+ console.log(`\n${gray(`Data available from ${String(data.period.available_from).slice(0, 10)}.`)}`)
121
+ }
122
+ for (const w of (warnings || [])) print_warn(w)
123
+ }
124
+
125
+ const run = (args) => catch_errors('Stats failed', async () => {
126
+ if (args.flags._show_help) {
127
+ print_help(HELP_TEXT)
128
+ return process.exit(EXIT_CODES.SUCCESS)
129
+ }
130
+
131
+ const body = build_request(args.flags)
132
+
133
+ // Auth required — fail fast with the standard auth-required envelope
134
+ // (next_step → login) before any network call.
135
+ const [, token_data] = await token_store.load_token()
136
+ if (!token_data) throw new AuthError()
137
+
138
+ // unwrap:false → the API already emits the canonical six-key envelope; we
139
+ // pass its data / warnings / next_step straight through.
140
+ const [errors, resp] = await client.post('/me/stats:query', body, { auth: true, unwrap: false })
141
+ if (errors) throw e('Stats request failed', errors)
142
+
143
+ const data = (resp && resp.data) || {}
144
+ const warnings = (resp && resp.warnings) || []
145
+ const next_step = (resp && resp.next_step) || {}
146
+
147
+ if (args.flags.json) {
148
+ print_json({ data, warnings, next_step })
149
+ return
150
+ }
151
+
152
+ render_human(data, warnings)
153
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
154
+
155
+ const schema = {
156
+ name: 'stats',
157
+ audience: 'consumer',
158
+ purpose: 'Retrieve your own consumption stats (my_activity) or aggregate reach of skills you authored (my_skills_reach) over a bounded window. Authenticated, read-only; never identifies who consumed your skills.',
159
+ mutation: false,
160
+ interactive_in_text_mode: false,
161
+ input: {
162
+ positional: [],
163
+ flags: [
164
+ { name: 'scope', type: 'string', required: true, description: 'my_activity | my_skills_reach' },
165
+ { name: 'metric', type: 'string', required: true, description: 'my_activity: installs|updates|searches|uninstalls; my_skills_reach: installs_by_others|distinct_installers' },
166
+ { name: 'period', type: 'string', default: undefined, description: 'Window preset: 7d|30d|90d|6mo|12mo (use instead of --from/--to)' },
167
+ { name: 'from', type: 'string', default: undefined, description: 'Explicit window start (ISO date)' },
168
+ { name: 'to', type: 'string', default: undefined, description: 'Explicit window end (ISO date, default now)' },
169
+ { name: 'group-by', type: 'string', default: 'none', description: 'my_activity: none|day|week|month|surface; my_skills_reach: none|day|week|month|skill' },
170
+ { name: 'skill', type: 'string', default: undefined, description: 'my_skills_reach only: limit to one authored skill (owner/skill)' },
171
+ { name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
172
+ ],
173
+ },
174
+ output: {
175
+ data_shape: {
176
+ scope: 'string',
177
+ metric: 'string',
178
+ group_by: 'string',
179
+ period: '{ from: string, to: string, available_from: string|null }',
180
+ total: 'number',
181
+ series: 'array<{ key: string, value: number }>',
182
+ },
183
+ },
184
+ errors: [
185
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
186
+ { code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
187
+ // Terminal errors the agent surfaces as-is — no recovery action.
188
+ { code: 'INVALID_BODY' },
189
+ { code: 'NOT_FOUND' },
190
+ { code: 'NETWORK_ERROR', next_step: { kind: 'recovery', action: 'retry' } },
191
+ ],
192
+ examples: [
193
+ 'happyskills stats --scope my_activity --metric installs --period 30d',
194
+ 'happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --group-by skill',
195
+ 'happyskills stats --scope my_skills_reach --metric installs_by_others --period 12mo --skill acme/deploy-aws',
196
+ ],
197
+ }
198
+
199
+ module.exports = { run, build_request, schema }
@@ -0,0 +1,123 @@
1
+ // Unit tests for `happyskills stats` (spec 260603-03). Two surfaces:
2
+ // 1. build_request — flag → closed-grammar validation + body construction.
3
+ // 2. run(--json) — the envelope passthrough: the API already emits the
4
+ // canonical six-key envelope; the command forwards data/warnings/next_step
5
+ // through the CLI's emit_envelope chokepoint unchanged.
6
+ //
7
+ // Covered by `npm test` (src/**/*.test.js).
8
+
9
+ const { describe, it, mock } = require('node:test')
10
+ const assert = require('node:assert/strict')
11
+ const { build_request, run, schema } = require('./stats')
12
+ const client = require('../api/client')
13
+ const token_store = require('../auth/token_store')
14
+ const state = require('../state')
15
+
16
+ describe('build_request — closed grammar', () => {
17
+ it('builds a my_activity preset body', () => {
18
+ assert.deepEqual(build_request({ scope: 'my_activity', metric: 'installs', period: '30d' }), {
19
+ scope: 'my_activity', metric: 'installs', group_by: 'none', period: { preset: '30d' },
20
+ })
21
+ })
22
+
23
+ it('builds an explicit from/to body with a time grain', () => {
24
+ assert.deepEqual(build_request({ scope: 'my_activity', metric: 'searches', from: '2026-05-01', to: '2026-06-01', 'group-by': 'week' }), {
25
+ scope: 'my_activity', metric: 'searches', group_by: 'week', period: { from: '2026-05-01', to: '2026-06-01' },
26
+ })
27
+ })
28
+
29
+ it('rejects an out-of-enum scope', () => {
30
+ assert.throws(() => build_request({ scope: 'nope', metric: 'installs', period: '7d' }), /scope/)
31
+ })
32
+
33
+ it('rejects a metric not valid for the scope', () => {
34
+ assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'searches', period: '7d' }), /metric/)
35
+ })
36
+
37
+ it('rejects a group-by not valid for the scope (surface is my_activity-only)', () => {
38
+ assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '7d', 'group-by': 'surface' }), /group-by/)
39
+ })
40
+
41
+ it('requires exactly one time window', () => {
42
+ assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs' }), /time window/)
43
+ assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '7d', from: '2026-01-01' }), /not both/)
44
+ })
45
+
46
+ it('rejects an out-of-enum preset', () => {
47
+ assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '13mo' }), /period/)
48
+ })
49
+
50
+ it('maps --skill to repo_slug for my_skills_reach', () => {
51
+ const body = build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '12mo', skill: 'acme/deploy-aws' })
52
+ assert.equal(body.repo_slug, 'acme/deploy-aws')
53
+ })
54
+
55
+ it('rejects --skill on my_activity', () => {
56
+ assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '30d', skill: 'acme/x' }), /--skill is only valid/)
57
+ })
58
+
59
+ it('rejects a --skill without owner/skill form', () => {
60
+ assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '12mo', skill: 'deploy' }), /owner\/skill/)
61
+ })
62
+ })
63
+
64
+ describe('schema export', () => {
65
+ it('is non-mutating, consumer-audience, with scope+metric required', () => {
66
+ assert.equal(schema.name, 'stats')
67
+ assert.equal(schema.mutation, false)
68
+ assert.equal(schema.audience, 'consumer')
69
+ const required = schema.input.flags.filter(f => f.required).map(f => f.name).sort()
70
+ assert.deepEqual(required, ['metric', 'scope'])
71
+ })
72
+
73
+ it('declares the AUTH_REQUIRED → login recovery contract', () => {
74
+ const auth = schema.errors.find(e => e.code === 'AUTH_REQUIRED')
75
+ assert.ok(auth)
76
+ assert.equal(auth.next_step.action, 'login')
77
+ })
78
+ })
79
+
80
+ describe('run --json — envelope passthrough', () => {
81
+ it('forwards the API data + warnings through a six-key envelope on stdout', async (t) => {
82
+ state.set_json_mode()
83
+ t.mock.method(token_store, 'load_token', async () => [null, { id_token: 'tok' }])
84
+ t.mock.method(client, 'post', async () => [null, {
85
+ ok: true,
86
+ data: { scope: 'my_activity', metric: 'installs', group_by: 'none', period: { from: 'a', to: 'b', available_from: null }, total: 5, series: [] },
87
+ error: {},
88
+ next_step: {},
89
+ warnings: ['Data is only available from 2026-05-25; earlier dates in your range return no events.'],
90
+ meta: {},
91
+ }])
92
+
93
+ let printed = null
94
+ const log = mock.method(console, 'log', (s) => { printed = s })
95
+
96
+ await run({ flags: { scope: 'my_activity', metric: 'installs', period: '30d', json: true } })
97
+ log.mock.restore()
98
+
99
+ const env = JSON.parse(printed)
100
+ // Six-key envelope, data forwarded, warning preserved.
101
+ for (const k of ['ok', 'data', 'error', 'next_step', 'warnings', 'meta']) assert.ok(k in env, `missing ${k}`)
102
+ assert.equal(env.ok, true)
103
+ assert.equal(env.data.total, 5)
104
+ assert.equal(env.warnings.length, 1)
105
+ })
106
+
107
+ it('sends the request to POST /me/stats:query with the built body', async (t) => {
108
+ state.set_json_mode()
109
+ t.mock.method(token_store, 'load_token', async () => [null, { id_token: 'tok' }])
110
+ let captured = null
111
+ t.mock.method(client, 'post', async (path, body, opts) => {
112
+ captured = { path, body, opts }
113
+ return [null, { ok: true, data: {}, error: {}, next_step: {}, warnings: [], meta: {} }]
114
+ })
115
+ const log = mock.method(console, 'log', () => {})
116
+ await run({ flags: { scope: 'my_skills_reach', metric: 'distinct_installers', period: '12mo', 'group-by': 'skill', json: true } })
117
+ log.mock.restore()
118
+
119
+ assert.equal(captured.path, '/me/stats:query')
120
+ assert.equal(captured.opts.unwrap, false)
121
+ assert.deepEqual(captured.body, { scope: 'my_skills_reach', metric: 'distinct_installers', group_by: 'skill', period: { preset: '12mo' } })
122
+ })
123
+ })
package/src/constants.js CHANGED
@@ -82,6 +82,7 @@ const COMMANDS = [
82
82
  'reconcile',
83
83
  'release',
84
84
  'feedback',
85
+ 'stats',
85
86
  'schema'
86
87
  ]
87
88
 
package/src/index.js CHANGED
@@ -131,6 +131,7 @@ Commands:
131
131
  reconcile <owner/skill> Diagnose and repair lock-vs-disk drift
132
132
  release <skill-name> Atomic release: snapshot + validate + bump + publish
133
133
  feedback <category> Lodge feedback (bug, wish, compliment, question, other)
134
+ stats Your usage + reach of skills you authored (requires login)
134
135
 
135
136
  Global flags:
136
137
  --help Show help for a command