happyskills 1.4.1 → 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,12 @@ 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
+
10
16
  ## [1.4.1] - 2026-06-03
11
17
 
12
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.4.1",
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)",
@@ -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