happyskills 1.4.1 → 1.6.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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.0] - 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - Extend `stats` (all additive): `--group-by skill` on `--scope my_activity` (rank which skills *you* installed); a new `installs` metric on `--scope my_skills_reach` that counts total installs of your authored skills (you + others; bot excluded), with `--exclude-self` to drop your own. Availability warnings are now softer — suppressed for `--period` presets, terser for explicit `--from`. Requires API v5.4.0+.
15
+
16
+ ## [1.5.0] - 2026-06-04
17
+
18
+ ### Added
19
+
20
+ - 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`.
21
+
10
22
  ## [1.4.1] - 2026-06-03
11
23
 
12
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.4.1",
3
+ "version": "1.6.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,213 @@
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', 'installs_by_others', 'distinct_installers'],
19
+ }
20
+ const GROUP_BY_BY_SCOPE = {
21
+ my_activity: ['none', 'day', 'week', 'month', 'surface', 'skill'],
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 (everyone), installs_by_others,
39
+ distinct_installers
40
+ --period <preset> ${VALID_PRESETS.join(' | ')}
41
+ --from <ISO date> Explicit window start (use instead of --period)
42
+ --to <ISO date> Explicit window end (default: now)
43
+ --group-by <bucket> my_activity: none, day, week, month, surface, skill
44
+ my_skills_reach: none, day, week, month, skill
45
+ (default: none)
46
+ --skill <owner/skill> Limit my_skills_reach to one skill you authored
47
+ --exclude-self my_skills_reach 'installs' only: exclude your own installs
48
+ (installs counts you by default)
49
+ --json Output as JSON envelope
50
+
51
+ Examples:
52
+ happyskills stats --scope my_activity --metric installs --period 30d
53
+ happyskills stats --scope my_activity --metric installs --period 30d --group-by skill
54
+ happyskills stats --scope my_activity --metric searches --period 90d --group-by week --json
55
+ happyskills stats --scope my_skills_reach --metric installs --period 12mo --group-by skill
56
+ happyskills stats --scope my_skills_reach --metric installs --period 12mo --exclude-self
57
+ happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --skill acme/deploy-aws
58
+ happyskills stats --scope my_activity --metric installs --from 2026-05-01 --to 2026-06-01`
59
+
60
+ // Validate flags against the closed grammar and build the request body. Throws
61
+ // UsageError on bad input (server re-validates regardless). Returns the body.
62
+ const build_request = (flags) => {
63
+ const scope = flags.scope
64
+ if (!scope || !METRICS_BY_SCOPE[scope])
65
+ throw new UsageError('--scope is required and must be one of: my_activity, my_skills_reach')
66
+
67
+ const metric = flags.metric
68
+ if (!metric || !METRICS_BY_SCOPE[scope].includes(metric))
69
+ throw new UsageError(`--metric for ${scope} must be one of: ${METRICS_BY_SCOPE[scope].join(', ')}`)
70
+
71
+ const group_by = flags['group-by'] || 'none'
72
+ if (!GROUP_BY_BY_SCOPE[scope].includes(group_by))
73
+ throw new UsageError(`--group-by for ${scope} must be one of: ${GROUP_BY_BY_SCOPE[scope].join(', ')}`)
74
+
75
+ const has_preset = flags.period != null && flags.period !== true
76
+ const has_from = flags.from != null && flags.from !== true
77
+ if (has_preset && has_from)
78
+ throw new UsageError('Use either --period or --from/--to, not both.')
79
+ if (!has_preset && !has_from)
80
+ throw new UsageError(`A time window is required: --period <${VALID_PRESETS.join('|')}> or --from <date>.`)
81
+
82
+ let period
83
+ if (has_preset) {
84
+ if (!VALID_PRESETS.includes(flags.period))
85
+ throw new UsageError(`--period must be one of: ${VALID_PRESETS.join(', ')}`)
86
+ period = { preset: flags.period }
87
+ } else {
88
+ period = { from: flags.from }
89
+ if (flags.to != null && flags.to !== true) period.to = flags.to
90
+ }
91
+
92
+ const body = { scope, metric, group_by, period }
93
+
94
+ // --skill narrows my_skills_reach to a single authored skill. The server
95
+ // resolves the owner/skill slug to its id against your owned set.
96
+ const has_skill = flags.skill != null && flags.skill !== true
97
+ if (has_skill) {
98
+ if (scope !== 'my_skills_reach')
99
+ throw new UsageError('--skill is only valid for --scope my_skills_reach.')
100
+ if (!flags.skill.includes('/'))
101
+ throw new UsageError('--skill must be in owner/skill form, e.g. acme/deploy-aws.')
102
+ body.repo_slug = flags.skill
103
+ }
104
+
105
+ // --exclude-self refines my_skills_reach `installs` (which counts you by
106
+ // default). Meaningless elsewhere — the server enforces this too.
107
+ if (flags['exclude-self']) {
108
+ if (scope !== 'my_skills_reach')
109
+ throw new UsageError('--exclude-self is only valid for --scope my_skills_reach.')
110
+ body.exclude_self = true
111
+ }
112
+
113
+ return body
114
+ }
115
+
116
+ const render_human = (data, warnings) => {
117
+ const window = data.period && data.period.from
118
+ ? `${String(data.period.from).slice(0, 10)} → ${String(data.period.to).slice(0, 10)}`
119
+ : ''
120
+ console.log(`\n${bold(`${data.scope} · ${data.metric}`)}${window ? dim(` ${window}`) : ''}\n`)
121
+ print_label(' Total', cyan(String(data.total)))
122
+
123
+ if (Array.isArray(data.series) && data.series.length > 0) {
124
+ console.log('')
125
+ const rows = data.series.map(p => [String(p.key), String(p.value)])
126
+ print_table(['Bucket', 'Count'], rows)
127
+ } else if (data.total === 0) {
128
+ print_info('No events in this window.')
129
+ }
130
+
131
+ if (data.period && data.period.available_from) {
132
+ console.log(`\n${gray(`Data available from ${String(data.period.available_from).slice(0, 10)}.`)}`)
133
+ }
134
+ for (const w of (warnings || [])) print_warn(w)
135
+ }
136
+
137
+ const run = (args) => catch_errors('Stats failed', async () => {
138
+ if (args.flags._show_help) {
139
+ print_help(HELP_TEXT)
140
+ return process.exit(EXIT_CODES.SUCCESS)
141
+ }
142
+
143
+ const body = build_request(args.flags)
144
+
145
+ // Auth required — fail fast with the standard auth-required envelope
146
+ // (next_step → login) before any network call.
147
+ const [, token_data] = await token_store.load_token()
148
+ if (!token_data) throw new AuthError()
149
+
150
+ // unwrap:false → the API already emits the canonical six-key envelope; we
151
+ // pass its data / warnings / next_step straight through.
152
+ const [errors, resp] = await client.post('/me/stats:query', body, { auth: true, unwrap: false })
153
+ if (errors) throw e('Stats request failed', errors)
154
+
155
+ const data = (resp && resp.data) || {}
156
+ const warnings = (resp && resp.warnings) || []
157
+ const next_step = (resp && resp.next_step) || {}
158
+
159
+ if (args.flags.json) {
160
+ print_json({ data, warnings, next_step })
161
+ return
162
+ }
163
+
164
+ render_human(data, warnings)
165
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
166
+
167
+ const schema = {
168
+ name: 'stats',
169
+ audience: 'consumer',
170
+ 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.',
171
+ mutation: false,
172
+ interactive_in_text_mode: false,
173
+ input: {
174
+ positional: [],
175
+ flags: [
176
+ { name: 'scope', type: 'string', required: true, description: 'my_activity | my_skills_reach' },
177
+ { name: 'metric', type: 'string', required: true, description: 'my_activity: installs|updates|searches|uninstalls; my_skills_reach: installs|installs_by_others|distinct_installers' },
178
+ { name: 'period', type: 'string', default: undefined, description: 'Window preset: 7d|30d|90d|6mo|12mo (use instead of --from/--to)' },
179
+ { name: 'from', type: 'string', default: undefined, description: 'Explicit window start (ISO date)' },
180
+ { name: 'to', type: 'string', default: undefined, description: 'Explicit window end (ISO date, default now)' },
181
+ { name: 'group-by', type: 'string', default: 'none', description: 'my_activity: none|day|week|month|surface|skill; my_skills_reach: none|day|week|month|skill' },
182
+ { name: 'skill', type: 'string', default: undefined, description: 'my_skills_reach only: limit to one authored skill (owner/skill)' },
183
+ { name: 'exclude-self', type: 'boolean', default: false, description: "my_skills_reach 'installs' only: exclude your own installs (counted by default)" },
184
+ { name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
185
+ ],
186
+ },
187
+ output: {
188
+ data_shape: {
189
+ scope: 'string',
190
+ metric: 'string',
191
+ group_by: 'string',
192
+ period: '{ from: string, to: string, available_from: string|null }',
193
+ total: 'number',
194
+ series: 'array<{ key: string, value: number }>',
195
+ },
196
+ },
197
+ errors: [
198
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
199
+ { code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
200
+ // Terminal errors the agent surfaces as-is — no recovery action.
201
+ { code: 'INVALID_BODY' },
202
+ { code: 'NOT_FOUND' },
203
+ { code: 'NETWORK_ERROR', next_step: { kind: 'recovery', action: 'retry' } },
204
+ ],
205
+ examples: [
206
+ 'happyskills stats --scope my_activity --metric installs --period 30d --group-by skill',
207
+ 'happyskills stats --scope my_skills_reach --metric installs --period 12mo --group-by skill',
208
+ 'happyskills stats --scope my_skills_reach --metric installs --period 12mo --exclude-self',
209
+ 'happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --skill acme/deploy-aws',
210
+ ],
211
+ }
212
+
213
+ module.exports = { run, build_request, schema }
@@ -0,0 +1,143 @@
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
+ it('accepts group-by skill on my_activity (rank your own installs by skill)', () => {
64
+ const body = build_request({ scope: 'my_activity', metric: 'installs', period: '30d', 'group-by': 'skill' })
65
+ assert.equal(body.group_by, 'skill')
66
+ })
67
+
68
+ it('accepts the my_skills_reach `installs` metric', () => {
69
+ const body = build_request({ scope: 'my_skills_reach', metric: 'installs', period: '12mo' })
70
+ assert.equal(body.metric, 'installs')
71
+ assert.equal(body.exclude_self, undefined) // self included by default
72
+ })
73
+
74
+ it('maps --exclude-self to exclude_self for my_skills_reach', () => {
75
+ const body = build_request({ scope: 'my_skills_reach', metric: 'installs', period: '12mo', 'exclude-self': true })
76
+ assert.equal(body.exclude_self, true)
77
+ })
78
+
79
+ it('rejects --exclude-self on my_activity', () => {
80
+ assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '30d', 'exclude-self': true }), /--exclude-self is only valid/)
81
+ })
82
+ })
83
+
84
+ describe('schema export', () => {
85
+ it('is non-mutating, consumer-audience, with scope+metric required', () => {
86
+ assert.equal(schema.name, 'stats')
87
+ assert.equal(schema.mutation, false)
88
+ assert.equal(schema.audience, 'consumer')
89
+ const required = schema.input.flags.filter(f => f.required).map(f => f.name).sort()
90
+ assert.deepEqual(required, ['metric', 'scope'])
91
+ })
92
+
93
+ it('declares the AUTH_REQUIRED → login recovery contract', () => {
94
+ const auth = schema.errors.find(e => e.code === 'AUTH_REQUIRED')
95
+ assert.ok(auth)
96
+ assert.equal(auth.next_step.action, 'login')
97
+ })
98
+ })
99
+
100
+ describe('run --json — envelope passthrough', () => {
101
+ it('forwards the API data + warnings through a six-key envelope on stdout', async (t) => {
102
+ state.set_json_mode()
103
+ t.mock.method(token_store, 'load_token', async () => [null, { id_token: 'tok' }])
104
+ t.mock.method(client, 'post', async () => [null, {
105
+ ok: true,
106
+ data: { scope: 'my_activity', metric: 'installs', group_by: 'none', period: { from: 'a', to: 'b', available_from: null }, total: 5, series: [] },
107
+ error: {},
108
+ next_step: {},
109
+ warnings: ['Data is only available from 2026-05-25; earlier dates in your range return no events.'],
110
+ meta: {},
111
+ }])
112
+
113
+ let printed = null
114
+ const log = mock.method(console, 'log', (s) => { printed = s })
115
+
116
+ await run({ flags: { scope: 'my_activity', metric: 'installs', period: '30d', json: true } })
117
+ log.mock.restore()
118
+
119
+ const env = JSON.parse(printed)
120
+ // Six-key envelope, data forwarded, warning preserved.
121
+ for (const k of ['ok', 'data', 'error', 'next_step', 'warnings', 'meta']) assert.ok(k in env, `missing ${k}`)
122
+ assert.equal(env.ok, true)
123
+ assert.equal(env.data.total, 5)
124
+ assert.equal(env.warnings.length, 1)
125
+ })
126
+
127
+ it('sends the request to POST /me/stats:query with the built body', async (t) => {
128
+ state.set_json_mode()
129
+ t.mock.method(token_store, 'load_token', async () => [null, { id_token: 'tok' }])
130
+ let captured = null
131
+ t.mock.method(client, 'post', async (path, body, opts) => {
132
+ captured = { path, body, opts }
133
+ return [null, { ok: true, data: {}, error: {}, next_step: {}, warnings: [], meta: {} }]
134
+ })
135
+ const log = mock.method(console, 'log', () => {})
136
+ await run({ flags: { scope: 'my_skills_reach', metric: 'distinct_installers', period: '12mo', 'group-by': 'skill', json: true } })
137
+ log.mock.restore()
138
+
139
+ assert.equal(captured.path, '/me/stats:query')
140
+ assert.equal(captured.opts.unwrap, false)
141
+ assert.deepEqual(captured.body, { scope: 'my_skills_reach', metric: 'distinct_installers', group_by: 'skill', period: { preset: '12mo' } })
142
+ })
143
+ })
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