ytstats 0.1.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/docs/cli.md ADDED
@@ -0,0 +1,285 @@
1
+ ---
2
+ description: Complete ytstats command reference — every command, flag, default, and exit code.
3
+ tags: [cli, commands, flags, reference]
4
+ source:
5
+ - src/cli.js
6
+ - src/dates.js
7
+ ---
8
+
9
+ # CLI Reference
10
+
11
+ Every command, every flag. For the shape of what comes back, see [output-contract.md](output-contract.md). For what the underlying API calls cost, see [youtube-apis.md](youtube-apis.md).
12
+
13
+ ## Invocation
14
+
15
+ ```bash
16
+ ytstats [global flags] <command> [command flags]
17
+ ```
18
+
19
+ Installed globally, or run without installing via `npx ytstats <command>`.
20
+
21
+ ## Global flags
22
+
23
+ Global flags go **before** the command name.
24
+
25
+ | Flag | Default | Effect |
26
+ |---|---|---|
27
+ | `-a, --account <channel>` | the default account | Channel id or `@handle` to use when several are signed in |
28
+ | `--compact` | off | Single-line JSON instead of pretty-printed |
29
+ | `-q, --quiet` | off | Suppress progress and warnings on stderr; stdout is unaffected |
30
+ | `-V, --version` | — | Print the package version and exit 0 |
31
+ | `-h, --help` | — | Print usage and exit 0 |
32
+
33
+ ```bash
34
+ ytstats --account UC1234567890 --compact daily --days 7
35
+ ```
36
+
37
+ `--account` accepts a channel id, a custom URL/handle, or a channel title — `loadAccount()` matches the id first, then falls back to a case-insensitive comparison against `customUrl` and `channelTitle`. An unrecognised selector fails with `AUTH_ACCOUNT_UNKNOWN`; it never silently falls back to the default.
38
+
39
+ ## Date window flags
40
+
41
+ Every analytics command accepts the same three flags:
42
+
43
+ | Flag | Default | Notes |
44
+ |---|---|---|
45
+ | `-d, --days <number>` | `90` | Days of history ending today (UTC) |
46
+ | `--start <date>` | — | `YYYY-MM-DD`; overrides `--days` |
47
+ | `--end <date>` | today (UTC) | `YYYY-MM-DD` |
48
+
49
+ Rules enforced before any network call:
50
+
51
+ - Dates must match `YYYY-MM-DD` exactly. A locale format such as `01/01/2026` fails with `INPUT_INVALID_DATE`.
52
+ - The date must exist on the calendar. `2026-02-31` is rejected — `Date` would otherwise roll it silently into March.
53
+ - `--days` must be a positive number, and at most **3650** (enforced in `src/dates.js`).
54
+ - `--start` must be on or before `--end`, checked only once both parse cleanly.
55
+
56
+ All input problems are reported **together**, in one envelope, before authentication:
57
+
58
+ ```bash
59
+ $ ytstats daily --start 01/01/2026 --end yesterday --days -3
60
+ # → 3 errors in one envelope: two INPUT_INVALID_DATE, one INPUT_INVALID_RANGE
61
+ ```
62
+
63
+ ## Authentication commands
64
+
65
+ ### login
66
+
67
+ ```bash
68
+ ytstats login [-c|--client-secret <path>] [--no-browser] [--timeout <seconds>]
69
+ ```
70
+
71
+ Runs the loopback OAuth flow: opens the browser, captures the callback on `127.0.0.1`, exchanges the code, fetches the channel identity, and stores both the OAuth client and the tokens.
72
+
73
+ | Flag | Default | Effect |
74
+ |---|---|---|
75
+ | `-c, --client-secret <path>` | resolution order below | Path to the `client_secret` JSON downloaded from Google Cloud |
76
+ | `--no-browser` | off | Print the URL and read the pasted redirect back — for SSH and headless machines |
77
+ | `--timeout <seconds>` | `300` | How long to wait for the browser callback, so an automated caller is never blocked indefinitely |
78
+
79
+ Credentials are resolved in this order: `--client-secret` → `YTSTATS_CLIENT_ID`/`YTSTATS_CLIENT_SECRET` → stored `credentials.json` → `client_secret*.json` in the working directory. See [configuration.md](configuration.md).
80
+
81
+ Returns `{ channelId, channelTitle, customUrl, configDir }`.
82
+
83
+ ```bash
84
+ ytstats login --client-secret ~/Downloads/client_secret_1234.json
85
+ ytstats login --no-browser # headless
86
+ ```
87
+
88
+ ### logout
89
+
90
+ ```bash
91
+ ytstats logout [--all] [--forget-credentials]
92
+ ```
93
+
94
+ Revokes the token with Google (best effort) and deletes it locally.
95
+
96
+ | Flag | Default | Effect |
97
+ |---|---|---|
98
+ | `--all` | off | Log out of every channel on this machine |
99
+ | `--forget-credentials` | off | Also delete the stored OAuth client id and secret |
100
+
101
+ Local removal happens even when revocation fails — being offline should not leave credentials on disk. Returns `{ loggedOut, revoked, accounts }`.
102
+
103
+ ### status
104
+
105
+ ```bash
106
+ ytstats status
107
+ ```
108
+
109
+ Reports who is signed in and where configuration lives. Takes no flags and needs no authentication.
110
+
111
+ Returns `{ authenticated, configDir, credentialSource, accounts[], setupGuide? }`. Each account carries `channelId`, `channelTitle`, `customUrl`, `savedAt`, and `isDefault` — never token material. `setupGuide` appears only when no account is signed in.
112
+
113
+ ### doctor
114
+
115
+ ```bash
116
+ ytstats doctor
117
+ ```
118
+
119
+ Runs four independent readiness checks, cheapest first, and reports all of them rather than stopping at the first failure:
120
+
121
+ | Check id | What it proves |
122
+ |---|---|
123
+ | `config_writable` | The config directory exists and accepts a write (probe file is written then removed) |
124
+ | `credentials` | An OAuth client resolved from some source |
125
+ | `signed_in` | At least one channel has stored tokens |
126
+ | `api_reachable` | A live `channels.list` call succeeds with the stored token |
127
+
128
+ `api_reachable` is skipped when earlier checks failed, since it cannot succeed.
129
+
130
+ `doctor` itself always succeeds — `ok: true`, exit 0. The verdict is `data.healthy`, and the blocking diagnostics are in `data.blocking`. Failures are also attached as envelope warnings. This is deliberate: a health check that exits non-zero when it finds a problem is reporting its own success incorrectly.
131
+
132
+ ### use
133
+
134
+ ```bash
135
+ ytstats use <channelId|@handle>
136
+ ```
137
+
138
+ Sets the default channel for subsequent commands. Fails if that channel is not signed in.
139
+
140
+ ### import-legacy
141
+
142
+ ```bash
143
+ ytstats import-legacy <tokensFile> [-c|--client-secret <path>]
144
+ ```
145
+
146
+ Imports tokens from a pre-`ytstats` project-local `tokens.json`. The legacy file holds no channel identity, so the tokens are exchanged for one before anything is stored. Never overwrites an account that already exists — it returns `{ migrated: false, reason }` instead, where `reason` is one of `no-legacy-file`, `no-refresh-token`, `unknown-channel`, or `already-logged-in`.
147
+
148
+ ## fetch
149
+
150
+ ```bash
151
+ ytstats fetch [--days 90] [--start <date>] [--end <date>]
152
+ [--no-retention] [--retention-limit <n>] [--reach]
153
+ ```
154
+
155
+ Every dimension in a single JSON document — the command to pipe into a script.
156
+
157
+ | Flag | Default | Effect |
158
+ |---|---|---|
159
+ | `--no-retention` | retention on | Skip retention curves, which cost one API call per video |
160
+ | `--retention-limit <number>` | `50` | How many recent videos to pull retention for, newest first |
161
+ | `--reach` | off | Also include thumbnail impressions and CTR from the Reporting API |
162
+
163
+ `data` contains `channel`, `videos`, `daily`, `videoAnalytics`, `trafficSources`, `trafficSourceDetails`, `demographics`, `deviceTypes`, `contentTypes`, `searchTerms`, `geography`, `playbackLocations`, `audienceRetention`, and `reach` when `--reach` is passed.
164
+
165
+ Alongside it: `period` (`{ startDate, endDate, days }`), `warnings` (per-step failures, each `{ step, code, message }`), and `notes` (informational, such as a truncated retention run).
166
+
167
+ Individual analytics steps degrade rather than abort — see [architecture.md](architecture.md#fetch-all-orchestration).
168
+
169
+ ```bash
170
+ ytstats fetch --days 90 > snapshot.json
171
+ ytstats fetch --no-retention --days 30 # cheapest full fetch
172
+ ytstats fetch --reach # includes CTR, if reports exist yet
173
+ ```
174
+
175
+ ## Dataset commands
176
+
177
+ All of these accept the date window flags above and return `{ period, rows }`. An empty `rows` array raises a `DATA_EMPTY` warning so "the query worked and found nothing" is distinguishable from "the query failed".
178
+
179
+ | Command | Returns | Extra flags |
180
+ |---|---|---|
181
+ | `channel` | Channel metadata and lifetime stats *(no period; no date flags)* | — |
182
+ | `videos` | Every video with metadata and current counts *(no period; no date flags)* | see below |
183
+ | `daily` | Day-by-day views, watch time, likes, comments, subscribers | — |
184
+ | `traffic` | Views by traffic source type | — |
185
+ | `demographics` | Viewer age and gender split | — |
186
+ | `devices` | Views by device type | — |
187
+ | `content-types` | Shorts vs long-form vs live, using YouTube's own `creatorContentType` | — |
188
+ | `search-terms` | What people search on YouTube to find the channel | `-n, --limit` (default `25`, capped at 25) |
189
+ | `geography` | Viewer breakdown by country | `-n, --limit` (default `50`) |
190
+ | `playback-locations` | Where viewers watch — Shorts feed, watch page, embedded | — |
191
+ | `video-analytics` | Per-video metrics, top 200 by views | — |
192
+
193
+ ### channel
194
+
195
+ ```bash
196
+ ytstats channel
197
+ ```
198
+
199
+ Returns the channel resource directly, not `{ period, rows }`: `id`, `title`, `description`, `customUrl`, `publishedAt`, `country`, `subscriberCount`, `viewCount`, `videoCount`, `uploadsPlaylistId`, `thumbnailUrl`.
200
+
201
+ ### videos
202
+
203
+ ```bash
204
+ ytstats videos [-n <number>] [-s <field>] [--order asc|desc] [-t <type>]
205
+ ```
206
+
207
+ | Flag | Default | Choices |
208
+ |---|---|---|
209
+ | `-n, --limit <number>` | all | — |
210
+ | `-s, --sort <field>` | `publishedAt` | `publishedAt`, `viewCount`, `likeCount`, `commentCount`, `durationSeconds` |
211
+ | `--order <dir>` | `desc` | `asc`, `desc` |
212
+ | `-t, --type <type>` | all | `SHORTS`, `VIDEO_ON_DEMAND`, `LIVE_STREAM` |
213
+
214
+ Filtering and sorting happen locally after every video is fetched, so `-n` reduces output size but not API cost. `contentType` is duration-based and can disagree with YouTube's own classification — see [the gotcha](gotchas/youtube-api.md#two-content-type-vocabularies-disagree).
215
+
216
+ ```bash
217
+ ytstats videos --type SHORTS --sort viewCount | jq '.data[0:5]'
218
+ ```
219
+
220
+ ### retention
221
+
222
+ ```bash
223
+ ytstats retention <videoId> [--days 90] [--start <date>] [--end <date>]
224
+ ```
225
+
226
+ Audience retention curve for one video — roughly 100 points at 1% intervals. Returns `{ videoId, period, curve }`, where each point is `{ position, ratio }`.
227
+
228
+ Ratios above `1.0` are correct and never clamped: a Short showing `1.54` means viewers looped it.
229
+
230
+ ## reach
231
+
232
+ ```bash
233
+ ytstats reach
234
+ ytstats reach-jobs
235
+ ```
236
+
237
+ `reach` is the only source of thumbnail impressions and CTR. It works unlike every other command because the data comes from the asynchronous Reporting API: the first run only *creates* the job, and reports appear 24-48 hours later with a 30-day backfill.
238
+
239
+ Returns `{ job, reportCount, pending, rows[] }`, plus `message` when pending. A pending result is a `REACH_PENDING` **warning**, not an error — the command succeeded, the data does not exist yet. Re-running is harmless and creates no duplicate job.
240
+
241
+ Rows carry `date`, `channelId`, `videoId`, `impressions`, and `impressionsCtr`. **`impressionsCtr` is a decimal fraction, not a percentage**: `0.0561` means 5.61%.
242
+
243
+ `reach-jobs` lists the Reporting API jobs on the channel.
244
+
245
+ ## query
246
+
247
+ ```bash
248
+ ytstats query -m <metrics> [--dimensions <list>] [--filters <expr>]
249
+ [--sort <field>] [-n <max>] [date flags]
250
+ ```
251
+
252
+ Escape hatch for an arbitrary YouTube Analytics API query.
253
+
254
+ | Flag | Required | Notes |
255
+ |---|---|---|
256
+ | `-m, --metrics <list>` | yes | Comma-separated, e.g. `views,likes` |
257
+ | `--dimensions <list>` | no | Comma-separated, e.g. `day` |
258
+ | `--filters <filters>` | no | e.g. `video==VIDEO_ID` |
259
+ | `--sort <field>` | no | Prefix with `-` for descending |
260
+ | `-n, --max <number>` | no | Maximum rows |
261
+
262
+ Returns `{ columns, rows }`, where `columns` is `[{ name, type }]` taken from the response's `columnHeaders`.
263
+
264
+ The Analytics API rejects many documented combinations, and which ones vary by channel — a rejection surfaces as `API_QUERY_NOT_SUPPORTED`. Notably `videoThumbnailImpressions` never works; use `reach` instead.
265
+
266
+ ```bash
267
+ ytstats query -m views,likes --dimensions day --start 2026-01-01
268
+ ```
269
+
270
+ ## Exit codes
271
+
272
+ | Code | Meaning |
273
+ |---|---|
274
+ | `0` | Success. Warnings do not change this. |
275
+ | `1` | General or unexpected failure |
276
+ | `2` | Authentication |
277
+ | `3` | Bad input |
278
+ | `4` | API error |
279
+
280
+ The code is also available as `meta.exitCode` in the envelope, so a consumer that can only read stdout still knows.
281
+
282
+ ```bash
283
+ ytstats fetch --days 30 2>/dev/null | jq '.data.channel.subscriberCount'
284
+ ytstats fetch 2>/dev/null | jq -r 'if .ok then "fine" else .nextSteps[0] end'
285
+ ```
@@ -0,0 +1,127 @@
1
+ ---
2
+ description: Environment variables, the per-user config directory, stored file formats, and CI setup.
3
+ tags: [configuration, environment, config-directory, ci]
4
+ source:
5
+ - src/config/**
6
+ - src/auth/credentials.js
7
+ ---
8
+
9
+ # Configuration
10
+
11
+ `ytstats` has no config file of its own. Everything is either an environment variable or state written by `login`. For the traps in the storage layer, see [gotchas/config-storage.md](gotchas/config-storage.md).
12
+
13
+ ## Environment variables
14
+
15
+ | Variable | Read by | Effect |
16
+ |---|---|---|
17
+ | `YTSTATS_CONFIG_DIR` | `resolveConfigDir()` | Overrides the config directory entirely. Relative values are resolved to absolute against the working directory |
18
+ | `YTSTATS_CLIENT_ID` | `resolveCredentials()` | OAuth client id. **Both** this and the secret must be set for the pair to be used |
19
+ | `YTSTATS_CLIENT_SECRET` | `resolveCredentials()` | OAuth client secret |
20
+ | `XDG_CONFIG_HOME` | `resolveConfigDir()` | Linux/BSD config base. **Ignored when relative**, per the XDG spec |
21
+ | `APPDATA` | `resolveConfigDir()` | Windows config base; falls back to `%USERPROFILE%\AppData\Roaming` |
22
+ | `HTTPS_PROXY` | `googleapis` / Node | Standard proxy variable, named in the `NETWORK_UNREACHABLE` remediation |
23
+
24
+ Note the deliberate asymmetry: a relative `XDG_CONFIG_HOME` is ignored because the spec says so, while a relative `YTSTATS_CONFIG_DIR` is accepted and resolved — it is an explicit override, not an environment convention.
25
+
26
+ ## The config directory
27
+
28
+ `resolveConfigDir({ platform, env, home })` picks the location, checking `YTSTATS_CONFIG_DIR` first and otherwise following platform convention:
29
+
30
+ | OS | Location |
31
+ |---|---|
32
+ | macOS | `~/Library/Application Support/ytstats/` |
33
+ | Linux / BSD | `$XDG_CONFIG_HOME/ytstats/`, default `~/.config/ytstats/` |
34
+ | Windows | `%APPDATA%\ytstats\` |
35
+
36
+ The function is pure — platform, environment, and home directory are all parameters. Only `configDir()` reads `process.platform`, `process.env`, and `os.homedir()`. That is what lets the test suite assert Windows and Linux behaviour while running on macOS.
37
+
38
+ `ytstats status` and `ytstats doctor` both report the resolved path.
39
+
40
+ ## Stored files
41
+
42
+ Two files, both written by `login`.
43
+
44
+ ### credentials.json
45
+
46
+ The BYO OAuth client, saved so later commands need no flags — and because Google's token endpoint requires the client secret on **every** refresh, not just the initial exchange.
47
+
48
+ ```jsonc
49
+ {
50
+ "version": 1,
51
+ "clientId": "123456789012-abc.apps.googleusercontent.com",
52
+ "clientSecret": "GOCSPX-…",
53
+ "source": "/Users/you/Downloads/client_secret_1234.json",
54
+ "savedAt": "2026-07-27T10:00:00.000Z"
55
+ }
56
+ ```
57
+
58
+ `source` records where the credentials originally came from, for display only.
59
+
60
+ ### tokens.json
61
+
62
+ The multi-account token store, keyed by channel id:
63
+
64
+ ```jsonc
65
+ {
66
+ "version": 1,
67
+ "default": "UC…",
68
+ "accounts": {
69
+ "UC…": {
70
+ "channelId": "UC…",
71
+ "channelTitle": "…",
72
+ "customUrl": "@…",
73
+ "tokens": { "access_token": "…", "refresh_token": "…", "expiry_date": 0 },
74
+ "savedAt": "2026-07-27T10:00:00.000Z"
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ Deleted entirely when the last account is removed. See [auth.md](auth.md#token-storage) for how it is read and merged.
81
+
82
+ ## Permissions and write discipline
83
+
84
+ Everything the store holds is a secret, so:
85
+
86
+ - The directory is created at `0700` and the mode is re-asserted with `chmod`, since `mkdir`'s mode is masked by umask and the directory may predate the run.
87
+ - Files are written at `0600`. The temp file is created **at** that mode rather than chmod'd afterwards, so the secret is never briefly world-readable.
88
+ - Writes are atomic: a uniquely-named temp file in the same directory, then `rename`. A crash never leaves a half-written token file, and a concurrent reader never sees one.
89
+ - File names must be flat basenames. Separators, `..`, absolute paths, and NUL are **rejected**, not sanitised.
90
+
91
+ On Windows POSIX modes are meaningless; the protection there is the per-user `%APPDATA%` location.
92
+
93
+ These are plaintext files, the same approach `gcloud`, `gh`, and `aws` take. `ytstats logout` revokes the token with Google and deletes them.
94
+
95
+ ## CI and headless machines
96
+
97
+ Supply the OAuth client through the environment instead of a file:
98
+
99
+ ```bash
100
+ export YTSTATS_CLIENT_ID=123456789012-abc.apps.googleusercontent.com
101
+ export YTSTATS_CLIENT_SECRET=GOCSPX-…
102
+ export YTSTATS_CONFIG_DIR=$PWD/.ytstats # if $HOME is not writable
103
+ ytstats login --no-browser
104
+ ```
105
+
106
+ Both `YTSTATS_CLIENT_ID` and `YTSTATS_CLIENT_SECRET` must be set — one alone is ignored and resolution falls through to the next source.
107
+
108
+ `--no-browser` prints the authorization URL and reads the pasted redirect back, so a machine with no browser can still complete the flow. The refresh token it produces is then reusable, provided the consent screen is published to Production — in Testing mode Google expires it after 7 days.
109
+
110
+ If the config directory is not writable, `doctor` reports `CONFIG_UNWRITABLE` and its remediation points at `YTSTATS_CONFIG_DIR`.
111
+
112
+ ## Credential resolution order
113
+
114
+ Ordered in `resolveCredentials()`; the first complete pair wins:
115
+
116
+ 1. `--client-secret <file>`
117
+ 2. `YTSTATS_CLIENT_ID` + `YTSTATS_CLIENT_SECRET` (both required)
118
+ 3. Stored `credentials.json`
119
+ 4. `client_secret*.json` auto-discovered in the working directory
120
+
121
+ Auto-discovery prefers an exact `client_secret.json`, otherwise takes the alphabetically first match so the same directory always resolves the same way. Full detail in [auth.md](auth.md#credential-resolution).
122
+
123
+ ## Not committing credentials
124
+
125
+ The repository's `.gitignore` excludes `client_secret*.json`, `credentials.json`, `tokens.json`, and `.env` — because a downloaded client secret can easily land in the working directory during testing, and auto-discovery means it will be picked up from there.
126
+
127
+ The stored copies live in the per-user config directory, outside any repository.
@@ -0,0 +1,155 @@
1
+ ---
2
+ description: How to extend ytstats — adding datasets, commands, and diagnostics; the dependency policy; and the release process.
3
+ tags: [contributing, workflow, dependencies, release, versioning]
4
+ source:
5
+ - package.json
6
+ - CHANGELOG.md
7
+ - vitest.config.js
8
+ ---
9
+
10
+ # Contributing
11
+
12
+ How to change `ytstats` without breaking the contracts it makes. Read [architecture.md](architecture.md) first for how the pieces fit together.
13
+
14
+ ## Adding a new dataset
15
+
16
+ 1. **Add the fetcher** to the relevant `src/api/*.js`, taking `apis` as its first argument so it stays injectable.
17
+ 2. **Add a test asserting the exact query parameters**, not just the return shape. That is what protects the undocumented API limits — a test that only checks returned rows lets someone raise `maxResults` and reintroduce an opaque failure.
18
+ 3. **Wire it into `fetch-all.js` behind `step()`** so a failure degrades to a warning rather than aborting the whole run.
19
+ 4. **Add a dedicated command in `cli.js`** if it is independently useful. Most analytics datasets can use the `simple()` helper, which supplies the date flags, the range validation, and the `DATA_EMPTY` warning.
20
+ 5. **Add a diagnostic to `diagnostics.js`** if it introduces a new failure mode. The catalog test fails unless the entry has a title, detail, cause, and at least one remediation step.
21
+ 6. **Update [cli.md](cli.md), [youtube-apis.md](youtube-apis.md), and `CHANGELOG.md`.**
22
+
23
+ If YouTube rejects a metric or dimension combination in a way that is not obvious, add it to [gotchas/youtube-api.md](gotchas/youtube-api.md) naming the handling site — so the next person does not undo the workaround.
24
+
25
+ ## Adding a command
26
+
27
+ Commands live in `src/cli.js`, grouped by section. Three helpers exist:
28
+
29
+ - **`run(name, body, { validate })`** wraps a command body: diagnostics in, one envelope plus exit code out. The `validate` callback runs **before** authentication and returns an array of diagnostics rather than throwing, so every input problem is reported at once.
30
+ - **`simple(name, description, fn)`** defines a date-windowed analytics command with the standard flags, range validation, and empty-result warning. Returns the command so extra options can be chained.
31
+ - **`dateOptions(cmd)`** adds `--days`, `--start`, and `--end` to a command built by hand.
32
+
33
+ Use `withApis(globalOpts)` to authenticate and get the API bundle. Never write to stdout directly — go through `reporter.succeed()` or `reporter.fail()`, or you break the one-JSON-document guarantee.
34
+
35
+ Commander's negated options read inverted: `--no-retention` surfaces as `cmdOpts.retention`, defaulting to `true`.
36
+
37
+ ## Adding a diagnostic
38
+
39
+ Entries in `DIAGNOSTICS` (`src/diagnostics.js`) are built with the `def()` helper, which defaults `severity` to `error` and `exitCode` to `EXIT.GENERAL`. A complete entry needs:
40
+
41
+ ```js
42
+ MY_NEW_CODE: def({
43
+ code: 'MY_NEW_CODE',
44
+ exitCode: EXIT.API,
45
+ recoverable: true, // can this be fixed at all?
46
+ retryable: false, // would re-running the SAME command help?
47
+ title: '…',
48
+ detail: '…',
49
+ cause: '…',
50
+ remediation: { summary: '…', steps: ['…'], commands: [DOCTOR_CMD], docs: ['…'] },
51
+ })
52
+ ```
53
+
54
+ Get `recoverable` and `retryable` right — they are the anti-loop signals an agent depends on. Marking something `retryable: true` when a retry cannot help sends a caller into an infinite loop.
55
+
56
+ Use `defaults: { … }` for context values that must appear even when a call site forgets to pass them, as `INPUT_INVALID_DATE` does with `expected`.
57
+
58
+ Throw it with `fail(DIAGNOSTICS.MY_NEW_CODE, { … })` rather than constructing a `YtStatsError` by hand.
59
+
60
+ ## The stability contract
61
+
62
+ **`code` values are public API.** Scripts and agents consuming `ytstats` JSON branch on them. Add new codes freely; never repurpose an existing one, and never delete one without a major version bump.
63
+
64
+ The same applies to the envelope's shape invariance — every key present on every response — and to the rule that `data` is `null` whenever `ok` is false. See [output-contract.md](output-contract.md).
65
+
66
+ ## Dependency policy
67
+
68
+ **No native dependencies.** `npx ytstats` must start instantly, which rules out anything requiring a prebuild download or a node-gyp compile. Runtime dependencies are `commander`, `googleapis`, and `open` — all pure JS. Think hard before adding a fourth.
69
+
70
+ Node 18 or newer is required (`engines.node`).
71
+
72
+ ### The gaxios override
73
+
74
+ `package.json` pins `gaxios` above its transitive default:
75
+
76
+ ```jsonc
77
+ "overrides": { "gaxios": "^7.1.4" }
78
+ ```
79
+
80
+ `package.json` carries the rationale inline under `overridesRationale`: `googleapis-common` pins gaxios 7.1.3, which drags in `rimraf > glob > minimatch > brace-expansion` and a high-severity DoS advisory (GHSA-mh99-v99m-4gvg). gaxios 7.1.4+ dropped that chain. Same major version, so this is a patch-level floor rather than a compatibility risk.
81
+
82
+ **Remove the override once `googleapis-common` raises its own floor.** Keeping a stale override silently pins a dependency the upstream has moved past.
83
+
84
+ ## Testing requirements
85
+
86
+ Every change needs tests, and the suite must stay offline. See [testing.md](testing.md) for the injection seams available.
87
+
88
+ The two rules that catch the most regressions:
89
+
90
+ - Assert **exact query parameters** on API fetchers, since that is what pins the undocumented limits.
91
+ - Use `useTempConfigDir()` for anything touching the config store, so tests never read real credentials.
92
+
93
+ ## Release process
94
+
95
+ The package is published to npm. `files` in `package.json` limits the tarball to `bin/`, `src/`, `docs/`, `README.md`, `CHANGELOG.md`, and `LICENSE` — tests and config are not shipped.
96
+
97
+ A release has two halves, and the split is deliberate:
98
+
99
+ | Half | Steps | Automated? |
100
+ |---|---|---|
101
+ | **Cut the release** | Version bump, changelog, commit, tag | Yes — the `release-cli` skill |
102
+ | **Ship to production** | `git push`, `npm publish` | No — always manual |
103
+
104
+ Nothing publishes to npm without a human running `npm publish`. That boundary is the point, not an unfinished feature.
105
+
106
+ ### Cutting a release manually
107
+
108
+ These steps are the contract. The skill below automates them; it does not replace them.
109
+
110
+ 1. Make sure `npm test` passes. `prepublishOnly` runs it again and blocks publication on failure.
111
+ 2. Update `CHANGELOG.md`. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): move entries out of `## [Unreleased]` into a new version heading with its date, and update the link references at the bottom.
112
+ 3. Bump the version in `package.json` following [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Removing or repurposing a diagnostic `code`, changing the envelope shape, or dropping a command is a **major** bump — see [The stability contract](#the-stability-contract).
113
+ 4. Commit `package.json` and `CHANGELOG.md` only, as `chore(release): ytstats v<version>`.
114
+ 5. Tag it: `git tag -a v<version> -m "Release v<version>"`.
115
+
116
+ `meta.version` in the envelope is read from `package.json` at runtime, so it tracks the bump automatically.
117
+
118
+ ### Cutting a release with the release-cli skill
119
+
120
+ `release-cli` is an AI-agent skill that performs steps 1-5 above. It is **optional tooling, not a project dependency** — it lives in `.agents/skills/release-cli/` and is excluded from the npm tarball, so a clone or an npm install does not necessarily have it. Install it with [HappySkills](https://happyskills.ai) if it is absent.
121
+
122
+ ```bash
123
+ /release-cli # analyze changes and propose a bump
124
+ /release-cli minor "Added --json-lines" # force the bump level, supply a note
125
+ /release-cli unreleased # record changes only, no bump or tag
126
+ ```
127
+
128
+ **Three gates must pass before it will cut a release**, and none can be overridden:
129
+
130
+ 1. The working directory is clean. A release commits only `package.json` and `CHANGELOG.md`, so uncommitted code would produce a tag whose commit does not contain the changes it ships.
131
+ 2. `npm test` passes.
132
+ 3. `## [Unreleased]` is non-empty — it refuses to stamp an empty version.
133
+
134
+ **It warns on breaking changes rather than deciding for you.** The skill scans the diff for this project's documented major-bump triggers — a removed or repurposed diagnostic `code`, an envelope key that changed or became conditional, a dropped command, a remapped exit code, a dropped `src/index.js` export — and asks you to confirm the bump when it finds one. It is a `grep`-and-diff heuristic: it cannot see a code whose *meaning* changed while its string stayed the same, so it is a safety net, not a substitute for knowing what you changed.
135
+
136
+ **The `unreleased` ledger mode** records changes into `## [Unreleased]` without bumping or tagging. It appends rather than replaces, so several sessions working the same branch can each record their own work and the next release promotes the whole section — instead of one session reconstructing everyone's scope from `git log` at release time.
137
+
138
+ ### Shipping to production
139
+
140
+ After the tag exists, deployment is two manual commands:
141
+
142
+ ```bash
143
+ git push && git push --tags
144
+ npm publish
145
+ ```
146
+
147
+ `prepublishOnly` re-runs the full test suite as the last gate before the tarball is built.
148
+
149
+ > This repository currently has **no git remote configured**, so `git push` will fail until one is added. It also has no tags — `v0.1.0` shipped per the changelog but was never tagged, which leaves the `compare/v0.1.0...HEAD` link in `CHANGELOG.md` unresolved. Backfilling that tag would repair the link and give future releases a baseline to diff against.
150
+
151
+ ## Documentation
152
+
153
+ Documentation lives in `docs/`, indexed from [README.md](../README.md). Each file carries frontmatter declaring the `source` globs it documents, and `doc-manifest.json` at the project root is the derived retrieval index — regenerated, never hand-edited.
154
+
155
+ When a change touches code covered by a doc's `source` globs, update that doc in the same commit.