@drakulavich/oura-cli 0.5.1 → 0.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
@@ -6,6 +6,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-09-06
10
+
11
+ ### Fixed
12
+ - A global flag between a command and its subcommand made the CLI reject the flag's *value* as a command: `db --format json today` failed with `Unknown command "json"`, and `db --db <path> today` echoed the whole path back. Global flags are now lifted to the end of the command line wherever they appear, so the three spellings of `--format json db today` all work. Tokens after `--` are left alone. (#79)
13
+ - `--no-color` left citty's help output coloured, because citty reads `NO_COLOR` when its module is evaluated and the variable was only set afterwards. The rule moved into `src/lib/color-mode.ts` and is applied by `src/lib/apply-color-mode.ts`, which the entry point imports first. Piped help is plain too unless `FORCE_COLOR` asks otherwise, `FORCE_COLOR=0` included, which chalk reads as off. (#80)
14
+ - `describe`, `manifest`, `healthcheck` and `login` accepted an unknown `--format` (`xml`) and exited 0, while every other command exited 1 with `BAD_ARGS`. The value is validated in `assertKnownArgs`, which every command calls, instead of only in the resolver the JSON-only commands never reach. (#81)
15
+ - `db week` lost its column alignment whenever colour was on: `padStart` counted the ANSI escapes chalk had already wrapped each score in, so it padded by nothing. Piped output was correct, which is why the tests never saw it. Padding now measures visible width (`src/lib/pad.ts`), and `report` uses the same helper. (#82)
16
+ - An empty `--db` (or `OURA_DB_PATH`) was falsy, so it fell through to the default and every command silently opened — and migrated — the home cache at `~/.oura-cli/oura.db`. A wrapper expanding an unset variable into `--db "$VAR"` therefore wrote to the user's real database. The same went for `--db=` and a trailing `--db` with no value. All of them now fail with `BAD_ARGS`, like the blank-but-not-empty value already did, and an `OURA_DB_PATH` set to an empty string is an error rather than a request for the default. `doctor` reports it as an argument error too, instead of a `database: fail` health finding with a database exit code. (#76)
17
+ - `sync` fetched nothing, and said nothing, for any collection whose last stored day was after the end of the window. A timezone west of the one the cache was built in (`--tz`, `OURA_TZ`, a laptop that travelled) did this to every daily collection for up to a day; a row dated in the future — a tag on next month, which Oura allows — did it to that collection on every run from then on. A collection now resumes from its newest stored day *at or before* the end of the window, so a future-dated row is ignored rather than freezing the collection, and the days behind it are still fetched. An explicit `--from` after the end of the window is rejected with `BAD_ARGS`. (#69)
18
+ - `sync` never fetched the heart-rate samples Oura adds to days already behind the watermark: on a live account 8,004 `source='workout'` samples over nine days were missing from an incrementally synced cache and present in a fresh one, with nothing in the output to say so. A collection can now declare `syncLookbackDays`, and `hr` re-walks the last 14 days on every sync. The window `sync` reports still describes what the cache needed, so a lookback does not make every run announce a fortnight. Samples Oura later reclassifies (`awake` → `workout`) are still kept, since `heartrate` never deletes. (#68)
19
+ - `report` decided whether a day's activity was complete from the newest heart-rate sample, but Oura publishes heart-rate days after the daily summaries: right after a ring sync the report called three fully synced days "still accumulating". A day now counts as complete once a later day has its own activity record; heart-rate plays no part. `lastUpload` stays in the JSON as information and the note no longer quotes it. (#57)
20
+
21
+ ### Added
22
+ - Eight collections from the Oura OpenAPI spec (1.37), each available through `fetch <name>`, stored by `sync`, listed in `describe`/`manifest`, counted by `db stats`, with a JSON Schema under `docs/schemas/`: `resilience` (daily_resilience), `vo2max` (vO2_max — its table has existed since 0.1 and is finally populated), `sleep-time` (sleep_time), `session` (session), `rest-mode` (rest_mode_period), `tags` (enhanced_tag; the legacy `tag` endpoint is not offered), `ring` (ring_configuration) and `battery` (ring_battery_level, a timestamp-keyed timeseries fetched in pieces of at most 30 days like `hr`). Schema migration 3 creates the seven new tables. (#44)
23
+ - Collection registry: `rangeParams: 'none'` for snapshot endpoints that take no range. `fetch` rejects `--day`/`--from`/`--to`/`--days` for them with `BAD_ARGS` instead of ignoring the flags, and `sync` replaces the table with each response (a ring removed from the account disappears locally too) instead of keeping a watermark — also under `--from/--to`, since a snapshot has no history to backfill.
24
+
25
+ ### Changed
26
+ - The `sync` text summary lists every registry collection by its `fetch` name (`sleep 28 (+2) readiness 0 (+0)`), in a fixed-width grid whose column count follows the terminal width (up to four, and a single unpadded cell per line once two no longer fit — around 46 columns, sooner when a count is wide), instead of a hand-written list of nine tables. Cells no longer break across lines on a narrow terminal, and the counts line up. (#83)
27
+ - `sync` prints a progress line for every collection, including those that returned nothing, and names both the collection and its table (` + tags (enhanced_tags): 0 fetched, 0 new`) so the lines and the summary can be read together. A silent collection used to be indistinguishable from a failed one while the summary listed it anyway. (#83)
28
+
29
+ ## [0.5.2] - 2026-09-06
30
+
31
+ ### Fixed
32
+ - `db trends N` covers N calendar days ending today; it used to include one day more than the heading claimed. (#52)
33
+ - `report` no longer averages a day whose activity is still accumulating as if it were complete. A day counts as complete once it is over in the report timezone and the ring has uploaded past its end (the newest heart-rate sample in the cache is the proxy); activity and steps averages, the high-activity pattern and the steps recommendation stop at the last complete day, while sleep and readiness — final once they exist — still use the whole window. The partial day stays in the daily table, marked `*`, with one line explaining it. (#57)
34
+ - A 401/403 from the Oura API carries a hint (`oura-cli login` with a fresh token), like a missing token already did. (#54)
35
+
36
+ ### Changed
37
+ - `manifest`: `healthcheck.expects` lists the `error` field that `healthcheck` emits when `ok` is false. (#54)
38
+ - `report` JSON: `days[].partial`, `completeThrough`, `lastUpload` and `averages[].count` are added; nothing is removed or renamed. (#57)
39
+ - README's automation section no longer claims JSON Schemas for every output shape (they cover `fetch` and `doctor`), explains that `doctor` and `healthcheck` exit 0 with `ok: false` when the probe itself ran, and records two quirks kept for compatibility: `report --period month` returns `weekStart`/`weekEnd`, and `heartrate.day` is the date written in Oura's timestamp, UTC in practice. (#54)
40
+
9
41
  ## [0.5.1] - 2026-09-05
10
42
 
11
43
  Fixes from the 0.5.0 exploratory testing sessions: the seam between citty and the command runner, and the sync window.
@@ -329,6 +361,7 @@ Fixes from the 0.5.0 exploratory testing sessions: the seam between citty and th
329
361
  - Local SQLite cache at `~/.oura-cli/oura.db`.
330
362
  - Auth via `oura-cli login`, `OURA_TOKEN`, `OURA_TOKEN_PATH`, or `~/.oura-token`.
331
363
 
364
+ [0.5.2]: https://github.com/drakulavich/oura-cli/releases/tag/v0.5.2
332
365
  [0.5.1]: https://github.com/drakulavich/oura-cli/releases/tag/v0.5.1
333
366
  [0.5.0]: https://github.com/drakulavich/oura-cli/releases/tag/v0.5.0
334
367
  [0.4.4]: https://github.com/drakulavich/oura-cli/releases/tag/v0.4.4
package/README.md CHANGED
@@ -54,7 +54,7 @@ oura-cli sync # first sync fetches the last 30 days; later syncs resume from
54
54
  oura-cli report # weekly digest in the terminal
55
55
  ```
56
56
 
57
- Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
57
+ Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
58
58
 
59
59
  ### If something looks wrong
60
60
 
@@ -96,7 +96,7 @@ oura-cli report # weekly (default)
96
96
  oura-cli report --period month # 30-day window with weekly buckets
97
97
  ```
98
98
 
99
- Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block.
99
+ Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block. A day whose activity is still accumulating (today, or the last day before the ring stopped syncing) is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`.
100
100
 
101
101
  ### Trends and stats
102
102
 
@@ -116,7 +116,9 @@ oura-cli fetch workout --from 2026-05-01 --to 2026-05-31
116
116
  oura-cli fetch sleep-periods --day 2026-06-01 | jq '.[] | {day, type, average_hrv}'
117
117
  ```
118
118
 
119
- Collections: `sleep readiness activity hr spo2 stress workout sleep-periods cv-age`.
119
+ Collections: `sleep readiness activity hr spo2 stress workout sleep-periods cv-age resilience vo2max sleep-time session rest-mode tags ring battery`.
120
+
121
+ `ring` is a snapshot of your ring's hardware, not a day range, so it takes no `--day`/`--from`/`--days`. `battery` is a timeseries like `hr`: keyed by timestamp, fetched in pieces of at most 30 days.
120
122
 
121
123
  ### Piping to other tools
122
124
 
@@ -136,6 +138,9 @@ oura-cli db trends 90 > trends.json
136
138
  | Database path | `--db` | `OURA_DB_PATH` | `~/.oura-cli/oura.db` |
137
139
  | Timezone | `--tz` | `OURA_TZ` | system timezone, else `UTC` |
138
140
  | Output format | `--format` | | auto-detect (TTY → table) |
141
+ | Colour | `--no-color`| `NO_COLOR` | on for a terminal, off when piped |
142
+
143
+ These are global: they may appear anywhere on the command line, before the command, between a command and its subcommand, or at the end. `oura-cli --format json db today`, `oura-cli db --format json today` and `oura-cli db today --format json` are the same command.
139
144
 
140
145
  ## Security
141
146
 
@@ -162,6 +167,14 @@ This tool reads your personal health data — handle the token with care.
162
167
  | Workouts | Oura V2 `workout` | `workouts` |
163
168
  | Sleep model | Oura V2 `sleep` | `sleep_model` |
164
169
  | Cardiovascular age | Oura V2 `cardiovascular_age` | `cardiovascular_age` |
170
+ | Resilience | Oura V2 `daily_resilience` | `daily_resilience` |
171
+ | VO₂ max | Oura V2 `vO2_max` | `vo2max` |
172
+ | Sleep time | Oura V2 `sleep_time` | `sleep_time` |
173
+ | Sessions | Oura V2 `session` | `sessions` |
174
+ | Rest mode | Oura V2 `rest_mode_period` | `rest_mode_periods` |
175
+ | Tags | Oura V2 `enhanced_tag` | `enhanced_tags` |
176
+ | Ring | Oura V2 `ring_configuration` | `ring_configuration` |
177
+ | Battery | Oura V2 `ring_battery_level` | `ring_battery_level` |
165
178
 
166
179
  Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [citty](https://github.com/unjs/citty). Output styling: [chalk](https://github.com/chalk/chalk). One ~110 kB `dist/index.js`, no native deps.
167
180
 
@@ -170,10 +183,12 @@ Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [ci
170
183
  If you're driving the CLI from a script or LLM harness:
171
184
 
172
185
  - `oura-cli describe` — JSON manifest of every command, argument, and output schema. Agents discover capabilities without scraping `--help`.
173
- - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes.
186
+ - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false.
187
+ - Gate on `.ok`, not on the exit code: `doctor` exits 0 with `ok: false` for any warning-level check (no data yet, stale data, Oura API unreachable), and `healthcheck` exits 0 with `ok: false` for an unusable database (the probe itself ran). `doctor --offline` skips the token-validation call, and a skipped check still counts towards `ok`.
174
188
  - Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
175
189
  - Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
176
- - JSON Schemas under [`docs/schemas/`](docs/schemas/) describe every output shape, semver-stable. Per-collection schemas pin the identity fields (`id`, `day`, `timestamp`) and allow the rest of the Oura record through unchanged, so new upstream fields never break validation.
190
+ - JSON Schemas under [`docs/schemas/`](docs/schemas/) cover `fetch <collection>`, `doctor` and the `describe` manifest itself, semver-stable; `describe` names the schema next to each command. Per-collection schemas pin the identity fields (`id`, `day`, `timestamp`) and allow the rest of the Oura record through unchanged, so new upstream fields never break validation. The local-data commands (`sync`, `db *`, `report`) have no schema files yet; their shapes are versioned through the CHANGELOG.
191
+ - Two contract quirks, kept for compatibility: `report --period month` returns its window as `weekStart`/`weekEnd`, and `heartrate.day` (likewise `ring_battery_level.day`) in the cache is the date written in Oura's timestamp (UTC in practice) while every `--day`/`--tz` argument is local.
177
192
 
178
193
  Plays cleanly with [OpenClaw](https://github.com/openclaw/openclaw) — `oura-cli manifest` returns the tool-registry shape. A first-party `oura-mcp` companion is on the roadmap.
179
194
 
package/dist/index.js CHANGED
@@ -557,8 +557,26 @@ var init_source = __esm(() => {
557
557
  source_default = chalk;
558
558
  });
559
559
 
560
- // src/index.ts
560
+ // src/lib/apply-color-mode.ts
561
561
  init_source();
562
+
563
+ // src/lib/color-mode.ts
564
+ function forcesColor(value) {
565
+ return value !== undefined && value !== "" && value !== "0" && value !== "false";
566
+ }
567
+ function shouldDisableColor(argv, env, isTty) {
568
+ if (argv.includes("--no-color") || env.NO_COLOR)
569
+ return true;
570
+ return !isTty && !forcesColor(env.FORCE_COLOR);
571
+ }
572
+
573
+ // src/lib/apply-color-mode.ts
574
+ if (shouldDisableColor(process.argv, process.env, process.stdout.isTTY === true)) {
575
+ process.env.NO_COLOR = "1";
576
+ source_default.level = 0;
577
+ }
578
+
579
+ // src/index.ts
562
580
  import { readFileSync as readFileSync2 } from "fs";
563
581
 
564
582
  // node_modules/citty/dist/_chunks/libs/scule.mjs
@@ -1309,6 +1327,78 @@ CREATE VIEW IF NOT EXISTS v_sleep_detail AS
1309
1327
  average_hrv, average_heart_rate as avg_hr, lowest_heart_rate as lowest_hr, efficiency
1310
1328
  FROM sleep_model ORDER BY day DESC;
1311
1329
  `
1330
+ },
1331
+ {
1332
+ version: 3,
1333
+ sql: `
1334
+ CREATE TABLE IF NOT EXISTS daily_resilience (
1335
+ id TEXT PRIMARY KEY,
1336
+ day TEXT UNIQUE,
1337
+ level TEXT,
1338
+ sleep_recovery REAL,
1339
+ daytime_recovery REAL,
1340
+ stress REAL
1341
+ );
1342
+ CREATE TABLE IF NOT EXISTS sleep_time (
1343
+ id TEXT PRIMARY KEY,
1344
+ day TEXT UNIQUE,
1345
+ status TEXT,
1346
+ recommendation TEXT,
1347
+ bedtime_start_offset INTEGER,
1348
+ bedtime_end_offset INTEGER,
1349
+ day_tz INTEGER
1350
+ );
1351
+ CREATE TABLE IF NOT EXISTS sessions (
1352
+ id TEXT PRIMARY KEY,
1353
+ day TEXT,
1354
+ type TEXT,
1355
+ mood TEXT,
1356
+ start_datetime TEXT,
1357
+ end_datetime TEXT,
1358
+ heart_rate TEXT,
1359
+ heart_rate_variability TEXT,
1360
+ motion_count TEXT
1361
+ );
1362
+ CREATE INDEX IF NOT EXISTS idx_sessions_day ON sessions(day);
1363
+ CREATE TABLE IF NOT EXISTS rest_mode_periods (
1364
+ id TEXT PRIMARY KEY,
1365
+ day TEXT,
1366
+ end_day TEXT,
1367
+ start_time TEXT,
1368
+ end_time TEXT,
1369
+ episodes TEXT
1370
+ );
1371
+ CREATE INDEX IF NOT EXISTS idx_rest_mode_periods_day ON rest_mode_periods(day);
1372
+ CREATE TABLE IF NOT EXISTS enhanced_tags (
1373
+ id TEXT PRIMARY KEY,
1374
+ day TEXT,
1375
+ end_day TEXT,
1376
+ start_time TEXT,
1377
+ end_time TEXT,
1378
+ tag_type_code TEXT,
1379
+ comment TEXT,
1380
+ custom_name TEXT
1381
+ );
1382
+ CREATE INDEX IF NOT EXISTS idx_enhanced_tags_day ON enhanced_tags(day);
1383
+ CREATE TABLE IF NOT EXISTS ring_configuration (
1384
+ id TEXT PRIMARY KEY,
1385
+ color TEXT,
1386
+ design TEXT,
1387
+ firmware_version TEXT,
1388
+ hardware_type TEXT,
1389
+ set_up_at TEXT,
1390
+ size INTEGER
1391
+ );
1392
+ CREATE TABLE IF NOT EXISTS ring_battery_level (
1393
+ timestamp TEXT,
1394
+ level INTEGER,
1395
+ charging INTEGER,
1396
+ in_charger INTEGER,
1397
+ day TEXT
1398
+ );
1399
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
1400
+ CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
1401
+ `
1312
1402
  }
1313
1403
  ];
1314
1404
 
@@ -1324,11 +1414,18 @@ function dbError(what, err) {
1324
1414
  function asDbError(err) {
1325
1415
  return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
1326
1416
  }
1417
+ function requirePath(value, source) {
1418
+ if (value.trim() === "") {
1419
+ throw new CliError("BAD_ARGS", `${source} has no path`, `Pass a path to a SQLite file, or remove ${source} to fall back to the default database.`);
1420
+ }
1421
+ return value;
1422
+ }
1327
1423
  function getDbPath(explicit) {
1328
- if (explicit)
1329
- return explicit;
1330
- if (process.env.OURA_DB_PATH)
1331
- return process.env.OURA_DB_PATH;
1424
+ if (explicit !== undefined)
1425
+ return requirePath(explicit, "--db");
1426
+ const fromEnv = process.env.OURA_DB_PATH;
1427
+ if (fromEnv !== undefined)
1428
+ return requirePath(fromEnv, "OURA_DB_PATH");
1332
1429
  return resolve(homedir(), ".oura-cli", "oura.db");
1333
1430
  }
1334
1431
  function openDatabase(explicit) {
@@ -1438,7 +1535,7 @@ class OuraClient {
1438
1535
  const redacted = redactSecrets(rawBody);
1439
1536
  const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
1440
1537
  if (response.status === 401 || response.status === 403) {
1441
- throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`);
1538
+ throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`, "Run `oura-cli login` with a fresh Personal Access Token, or check OURA_TOKEN.");
1442
1539
  }
1443
1540
  throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
1444
1541
  }
@@ -1469,15 +1566,13 @@ var SUBCOMMANDS = new Set([
1469
1566
  ]);
1470
1567
  function normalizeArgv(argv) {
1471
1568
  const [bun, script, ...rest] = argv;
1472
- const subIdx = rest.findIndex((a) => SUBCOMMANDS.has(a));
1473
- if (subIdx < 0)
1474
- return argv;
1475
- const before = rest.slice(0, subIdx);
1476
- const subcommandOnwards = rest.slice(subIdx);
1569
+ const dd = rest.indexOf("--");
1570
+ const scanned = dd >= 0 ? rest.slice(0, dd) : rest;
1571
+ const passthrough = dd >= 0 ? rest.slice(dd) : [];
1572
+ const kept = [];
1477
1573
  const hoisted = [];
1478
- const leftover = [];
1479
- for (let i = 0;i < before.length; i++) {
1480
- const tok = before[i];
1574
+ for (let i = 0;i < scanned.length; i++) {
1575
+ const tok = scanned[i];
1481
1576
  if (tok.includes("=")) {
1482
1577
  const name = tok.slice(0, tok.indexOf("="));
1483
1578
  if (GLOBAL_FLAGS_WITH_VALUE.has(name) || GLOBAL_FLAGS_BOOLEAN.has(name)) {
@@ -1487,8 +1582,8 @@ function normalizeArgv(argv) {
1487
1582
  }
1488
1583
  if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
1489
1584
  hoisted.push(tok);
1490
- if (i + 1 < before.length) {
1491
- hoisted.push(before[i + 1]);
1585
+ if (i + 1 < scanned.length) {
1586
+ hoisted.push(scanned[i + 1]);
1492
1587
  i++;
1493
1588
  }
1494
1589
  continue;
@@ -1497,13 +1592,9 @@ function normalizeArgv(argv) {
1497
1592
  hoisted.push(tok);
1498
1593
  continue;
1499
1594
  }
1500
- leftover.push(tok);
1595
+ kept.push(tok);
1501
1596
  }
1502
- const dd = subcommandOnwards.indexOf("--");
1503
- if (dd >= 0) {
1504
- return [bun, script, ...leftover, ...subcommandOnwards.slice(0, dd), ...hoisted, ...subcommandOnwards.slice(dd)];
1505
- }
1506
- return [bun, script, ...leftover, ...subcommandOnwards, ...hoisted];
1597
+ return [bun, script, ...kept, ...hoisted, ...passthrough];
1507
1598
  }
1508
1599
  function isVersionRequest(rawArgs) {
1509
1600
  for (let i = 0;i < rawArgs.length; i++) {
@@ -1521,13 +1612,15 @@ function isVersionRequest(rawArgs) {
1521
1612
  }
1522
1613
 
1523
1614
  // src/lib/format-resolve.ts
1524
- function resolveFormat({ explicit, isTty }) {
1525
- if (explicit === undefined)
1526
- return isTty ? "table" : "json";
1527
- if (explicit === "table" || explicit === "json")
1528
- return explicit;
1615
+ function assertValidFormat(explicit) {
1616
+ if (explicit === undefined || explicit === "table" || explicit === "json")
1617
+ return;
1529
1618
  throw new CliError("BAD_ARGS", `Unknown --format value: "${explicit}". Use "table" or "json".`);
1530
1619
  }
1620
+ function resolveFormat({ explicit, isTty }) {
1621
+ assertValidFormat(explicit);
1622
+ return explicit ?? (isTty ? "table" : "json");
1623
+ }
1531
1624
  function formatFromArgv(argv, isTty) {
1532
1625
  let explicit;
1533
1626
  for (let i = 0;i < argv.length; i++) {
@@ -1659,6 +1752,8 @@ var processIo = {
1659
1752
  };
1660
1753
  var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1661
1754
  function assertKnownArgs(declared, args) {
1755
+ if ("format" in declared)
1756
+ assertValidFormat(args.format);
1662
1757
  const known = new Set(["_"]);
1663
1758
  let positionals = 0;
1664
1759
  for (const [name, def] of Object.entries(declared)) {
@@ -1891,6 +1986,7 @@ var hr = defineCollection({
1891
1986
  conflict: "ignore",
1892
1987
  rangeParams: "datetime",
1893
1988
  maxRangeDays: 30,
1989
+ syncLookbackDays: 14,
1894
1990
  identity: [{ field: "timestamp", format: "date-time", description: "ISO 8601 timestamp of the sample" }],
1895
1991
  columns: [
1896
1992
  { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
@@ -2028,6 +2124,192 @@ var cvAge = defineCollection({
2028
2124
  ]
2029
2125
  });
2030
2126
 
2127
+ // src/collections/resilience.ts
2128
+ var resilience = defineCollection({
2129
+ name: "resilience",
2130
+ endpoint: "daily_resilience",
2131
+ table: "daily_resilience",
2132
+ description: "Daily resilience level with its sleep-recovery, daytime-recovery and stress contributors",
2133
+ conflict: "replace",
2134
+ rangeParams: "date",
2135
+ identity: [
2136
+ { field: "id", description: "Oura record id" },
2137
+ { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
2138
+ ],
2139
+ columns: [
2140
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2141
+ { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
2142
+ { name: "level", type: "TEXT", pick: (r) => r.level ?? null },
2143
+ { name: "sleep_recovery", type: "REAL", pick: (r) => r.contributors?.sleep_recovery ?? null },
2144
+ { name: "daytime_recovery", type: "REAL", pick: (r) => r.contributors?.daytime_recovery ?? null },
2145
+ { name: "stress", type: "REAL", pick: (r) => r.contributors?.stress ?? null }
2146
+ ]
2147
+ });
2148
+
2149
+ // src/collections/vo2max.ts
2150
+ var vo2max = defineCollection({
2151
+ name: "vo2max",
2152
+ endpoint: "vO2_max",
2153
+ table: "vo2max",
2154
+ description: "Daily VO2 max estimate",
2155
+ conflict: "replace",
2156
+ rangeParams: "date",
2157
+ dayRangeOffset: [0, 1],
2158
+ identity: [
2159
+ { field: "id", description: "Oura record id" },
2160
+ { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
2161
+ ],
2162
+ columns: [
2163
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2164
+ { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
2165
+ { name: "vo2_max", type: "REAL", pick: (r) => r.vo2_max ?? null },
2166
+ { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
2167
+ ]
2168
+ });
2169
+
2170
+ // src/collections/sleep-time.ts
2171
+ var sleepTime = defineCollection({
2172
+ name: "sleep-time",
2173
+ endpoint: "sleep_time",
2174
+ table: "sleep_time",
2175
+ description: "Suggested bedtime window (offsets in seconds from midnight) with status and recommendation",
2176
+ conflict: "replace",
2177
+ rangeParams: "date",
2178
+ identity: [
2179
+ { field: "id", description: "Oura record id" },
2180
+ { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
2181
+ ],
2182
+ columns: [
2183
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2184
+ { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
2185
+ { name: "status", type: "TEXT", pick: (r) => r.status ?? null },
2186
+ { name: "recommendation", type: "TEXT", pick: (r) => r.recommendation ?? null },
2187
+ { name: "bedtime_start_offset", type: "INTEGER", pick: (r) => r.optimal_bedtime?.start_offset ?? null },
2188
+ { name: "bedtime_end_offset", type: "INTEGER", pick: (r) => r.optimal_bedtime?.end_offset ?? null },
2189
+ { name: "day_tz", type: "INTEGER", pick: (r) => r.optimal_bedtime?.day_tz ?? null }
2190
+ ]
2191
+ });
2192
+
2193
+ // src/collections/session.ts
2194
+ var session = defineCollection({
2195
+ name: "session",
2196
+ endpoint: "session",
2197
+ table: "sessions",
2198
+ description: "Guided sessions (meditation, breathing, naps, rest) with their sample series as JSON",
2199
+ conflict: "replace",
2200
+ rangeParams: "date",
2201
+ dayRangeOffset: [0, 1],
2202
+ identity: [
2203
+ { field: "id", description: "Oura record id" },
2204
+ { field: "day", format: "date", description: "Date the session belongs to (YYYY-MM-DD)" }
2205
+ ],
2206
+ columns: [
2207
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2208
+ { name: "day", type: "TEXT", pick: (r) => r.day },
2209
+ { name: "type", type: "TEXT", pick: (r) => r.type ?? null },
2210
+ { name: "mood", type: "TEXT", pick: (r) => r.mood ?? null },
2211
+ { name: "start_datetime", type: "TEXT", pick: (r) => r.start_datetime },
2212
+ { name: "end_datetime", type: "TEXT", pick: (r) => r.end_datetime },
2213
+ { name: "heart_rate", type: "TEXT", pick: (r) => r.heart_rate == null ? null : JSON.stringify(r.heart_rate) },
2214
+ { name: "heart_rate_variability", type: "TEXT", pick: (r) => r.heart_rate_variability == null ? null : JSON.stringify(r.heart_rate_variability) },
2215
+ { name: "motion_count", type: "TEXT", pick: (r) => r.motion_count == null ? null : JSON.stringify(r.motion_count) }
2216
+ ],
2217
+ indexes: [{ name: "idx_sessions_day", columns: ["day"] }]
2218
+ });
2219
+
2220
+ // src/collections/rest-mode.ts
2221
+ var restMode = defineCollection({
2222
+ name: "rest-mode",
2223
+ endpoint: "rest_mode_period",
2224
+ table: "rest_mode_periods",
2225
+ description: "Rest mode periods; `day` is the period start day, episodes are kept as JSON",
2226
+ conflict: "replace",
2227
+ rangeParams: "date",
2228
+ dayRangeOffset: [0, 1],
2229
+ identity: [
2230
+ { field: "id", description: "Oura record id" },
2231
+ { field: "start_day", format: "date", description: "First day of the period (YYYY-MM-DD)" }
2232
+ ],
2233
+ columns: [
2234
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2235
+ { name: "day", type: "TEXT", pick: (r) => r.start_day },
2236
+ { name: "end_day", type: "TEXT", pick: (r) => r.end_day ?? null },
2237
+ { name: "start_time", type: "TEXT", pick: (r) => r.start_time ?? null },
2238
+ { name: "end_time", type: "TEXT", pick: (r) => r.end_time ?? null },
2239
+ { name: "episodes", type: "TEXT", pick: (r) => r.episodes == null ? null : JSON.stringify(r.episodes) }
2240
+ ],
2241
+ indexes: [{ name: "idx_rest_mode_periods_day", columns: ["day"] }]
2242
+ });
2243
+
2244
+ // src/collections/tags.ts
2245
+ var tags = defineCollection({
2246
+ name: "tags",
2247
+ endpoint: "enhanced_tag",
2248
+ table: "enhanced_tags",
2249
+ description: "Tags the user added in the app; `day` is the tag start day",
2250
+ conflict: "replace",
2251
+ rangeParams: "date",
2252
+ dayRangeOffset: [0, 1],
2253
+ identity: [
2254
+ { field: "id", description: "Oura record id" },
2255
+ { field: "start_day", format: "date", description: "Day the tag starts on (YYYY-MM-DD)" }
2256
+ ],
2257
+ columns: [
2258
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2259
+ { name: "day", type: "TEXT", pick: (r) => r.start_day },
2260
+ { name: "end_day", type: "TEXT", pick: (r) => r.end_day ?? null },
2261
+ { name: "start_time", type: "TEXT", pick: (r) => r.start_time ?? null },
2262
+ { name: "end_time", type: "TEXT", pick: (r) => r.end_time ?? null },
2263
+ { name: "tag_type_code", type: "TEXT", pick: (r) => r.tag_type_code ?? null },
2264
+ { name: "comment", type: "TEXT", pick: (r) => r.comment ?? null },
2265
+ { name: "custom_name", type: "TEXT", pick: (r) => r.custom_name ?? null }
2266
+ ],
2267
+ indexes: [{ name: "idx_enhanced_tags_day", columns: ["day"] }]
2268
+ });
2269
+
2270
+ // src/collections/ring.ts
2271
+ var ring = defineCollection({
2272
+ name: "ring",
2273
+ endpoint: "ring_configuration",
2274
+ table: "ring_configuration",
2275
+ description: "Ring hardware, colour, size, firmware and set-up time; a snapshot list, not a day range",
2276
+ conflict: "replace",
2277
+ rangeParams: "none",
2278
+ identity: [{ field: "id", description: "Oura ring id" }],
2279
+ columns: [
2280
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
2281
+ { name: "color", type: "TEXT", pick: (r) => r.color ?? null },
2282
+ { name: "design", type: "TEXT", pick: (r) => r.design ?? null },
2283
+ { name: "firmware_version", type: "TEXT", pick: (r) => r.firmware_version ?? null },
2284
+ { name: "hardware_type", type: "TEXT", pick: (r) => r.hardware_type ?? null },
2285
+ { name: "set_up_at", type: "TEXT", pick: (r) => r.set_up_at ?? null },
2286
+ { name: "size", type: "INTEGER", pick: (r) => r.size ?? null }
2287
+ ]
2288
+ });
2289
+
2290
+ // src/collections/battery.ts
2291
+ var battery = defineCollection({
2292
+ name: "battery",
2293
+ endpoint: "ring_battery_level",
2294
+ table: "ring_battery_level",
2295
+ description: "Ring battery level events (percent) with charging state",
2296
+ conflict: "ignore",
2297
+ rangeParams: "datetime",
2298
+ maxRangeDays: 30,
2299
+ identity: [{ field: "timestamp", format: "date-time", description: "ISO 8601 timestamp of the event" }],
2300
+ columns: [
2301
+ { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
2302
+ { name: "level", type: "INTEGER", pick: (r) => r.level ?? null },
2303
+ { name: "charging", type: "INTEGER", pick: (r) => r.charging == null ? null : r.charging ? 1 : 0 },
2304
+ { name: "in_charger", type: "INTEGER", pick: (r) => r.in_charger == null ? null : r.in_charger ? 1 : 0 },
2305
+ { name: "day", type: "TEXT", pick: (r) => r.timestamp.slice(0, 10) }
2306
+ ],
2307
+ indexes: [
2308
+ { name: "idx_ring_battery_level_unique", columns: ["timestamp"], unique: true },
2309
+ { name: "idx_ring_battery_level_day", columns: ["day"] }
2310
+ ]
2311
+ });
2312
+
2031
2313
  // src/collections/index.ts
2032
2314
  var COLLECTIONS = [
2033
2315
  sleep,
@@ -2038,7 +2320,15 @@ var COLLECTIONS = [
2038
2320
  stress,
2039
2321
  workout,
2040
2322
  sleepPeriods,
2041
- cvAge
2323
+ cvAge,
2324
+ resilience,
2325
+ vo2max,
2326
+ sleepTime,
2327
+ session,
2328
+ restMode,
2329
+ tags,
2330
+ ring,
2331
+ battery
2042
2332
  ];
2043
2333
  function names() {
2044
2334
  return COLLECTIONS.map((c) => c.name);
@@ -2078,6 +2368,8 @@ function datetimeQueries(start, end, tz, maxDays) {
2078
2368
  return out;
2079
2369
  }
2080
2370
  function rangeQueries(c, start, end, tz) {
2371
+ if (c.rangeParams === "none")
2372
+ return [{}];
2081
2373
  if (start > end)
2082
2374
  return [];
2083
2375
  return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
@@ -2092,7 +2384,7 @@ async function fetchCollection(client, c, start, end, tz) {
2092
2384
  }
2093
2385
 
2094
2386
  // src/commands/describe.ts
2095
- var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json" };
2387
+ var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json", describe: "docs/schemas/describe.json" };
2096
2388
  var ENUM_ARGS = {
2097
2389
  fetch: { collection: names() },
2098
2390
  report: { period: ["week", "month"] }
@@ -2192,8 +2484,8 @@ function describeCommand(version, getCommands) {
2192
2484
 
2193
2485
  // src/db/sync.ts
2194
2486
  var BACKFILL_DAYS = 30;
2195
- function lastDay(db, table) {
2196
- return db.query(`SELECT MAX(day) AS d FROM ${table}`).get().d;
2487
+ function lastDay(db, table, end) {
2488
+ return db.query(`SELECT MAX(day) AS d FROM ${table} WHERE day <= ?`).get(end).d;
2197
2489
  }
2198
2490
  function rowCount(db, table) {
2199
2491
  return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
@@ -2204,26 +2496,44 @@ async function importDaily(db, client, clock, log, window = {}) {
2204
2496
  const end = window.to ?? today;
2205
2497
  const backfillStart = shiftDay(end, -(BACKFILL_DAYS - 1));
2206
2498
  const plan = COLLECTIONS.map((c) => {
2207
- const last = lastDay(db, c.table);
2208
- return { c, last, start: window.from ?? last ?? backfillStart };
2499
+ const last = c.rangeParams === "none" ? null : lastDay(db, c.table, end);
2500
+ const resume = window.from ?? last ?? backfillStart;
2501
+ const start = window.from ?? shiftDay(resume, -(last === null ? 0 : c.syncLookbackDays ?? 0));
2502
+ return { c, last, resume, start };
2209
2503
  });
2210
- const isFirstSync = plan.every((p) => p.last === null);
2211
- const startDate = plan.map((p) => p.start).sort()[0];
2504
+ const ranged = plan.filter((p) => p.c.rangeParams !== "none");
2505
+ const isFirstSync = ranged.every((p) => p.last === null);
2506
+ const startDate = ranged.map((p) => p.resume).sort()[0];
2212
2507
  _log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
2213
2508
  const fetched = {};
2214
2509
  const added = {};
2215
2510
  for (const { c, start } of plan) {
2216
2511
  const rows = await fetchCollection(client, c, start, end, tz);
2217
- const before = rowCount(db, c.table);
2218
2512
  const stmt = db.query(insertSql(c));
2513
+ if (c.rangeParams === "none") {
2514
+ const pk = c.columns.find((col) => col.pk)?.name;
2515
+ if (!pk)
2516
+ throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
2517
+ const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
2518
+ const known = ids();
2519
+ db.transaction((rs) => {
2520
+ db.exec(`DELETE FROM ${c.table}`);
2521
+ for (const r of rs)
2522
+ stmt.run(...rowValues(c, r));
2523
+ })(rows);
2524
+ fetched[c.table] = rows.length;
2525
+ added[c.table] = [...ids()].filter((id) => !known.has(id)).length;
2526
+ _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${rows.length === 0 ? ", table cleared" : ""}`);
2527
+ continue;
2528
+ }
2529
+ const before = rowCount(db, c.table);
2219
2530
  db.transaction((rs) => {
2220
2531
  for (const r of rs)
2221
2532
  stmt.run(...rowValues(c, r));
2222
2533
  })(rows);
2223
2534
  fetched[c.table] = rows.length;
2224
2535
  added[c.table] = rowCount(db, c.table) - before;
2225
- if (rows.length > 0)
2226
- _log(` + ${c.table}: ${rows.length} fetched, ${added[c.table]} new`);
2536
+ _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new`);
2227
2537
  }
2228
2538
  _log("Import complete.");
2229
2539
  return { startDate, endDate: end, fetched, added, isFirstSync };
@@ -2255,7 +2565,7 @@ function getDaySummary(db, day) {
2255
2565
  };
2256
2566
  }
2257
2567
  function getTrends(db, days, today) {
2258
- const start = shiftDay(today, -days);
2568
+ const start = shiftDay(today, -(days - 1));
2259
2569
  const results = [];
2260
2570
  const metrics = [
2261
2571
  ["Sleep Score", "daily_sleep", "score"],
@@ -2298,6 +2608,27 @@ function getStats(db, today) {
2298
2608
 
2299
2609
  // src/render/format.ts
2300
2610
  init_source();
2611
+
2612
+ // src/lib/pad.ts
2613
+ var ANSI = /\u001B\[[0-9;]*m/g;
2614
+ function visibleWidth(text) {
2615
+ return text.replace(ANSI, "").length;
2616
+ }
2617
+ function padLeft(text, width) {
2618
+ const gap = width - visibleWidth(text);
2619
+ return gap > 0 ? " ".repeat(gap) + text : text;
2620
+ }
2621
+ function padRight(text, width) {
2622
+ const gap = width - visibleWidth(text);
2623
+ return gap > 0 ? text + " ".repeat(gap) : text;
2624
+ }
2625
+
2626
+ // src/lib/terminal.ts
2627
+ function terminalWidth() {
2628
+ return process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : 80;
2629
+ }
2630
+
2631
+ // src/render/format.ts
2301
2632
  function scoreColor(score) {
2302
2633
  if (score === null)
2303
2634
  return source_default.gray("\u2014");
@@ -2351,13 +2682,35 @@ function formatDaySummary(summary, format, emptyHint) {
2351
2682
  return lines.join(`
2352
2683
  `);
2353
2684
  }
2354
- function formatImportSummary(result) {
2355
- const n = (table) => `${result.fetched[table] ?? 0} (+${result.added[table] ?? 0})`;
2356
- return [
2357
- ` Fetched ${result.startDate} \u2192 ${result.endDate}, rows fetched (+new):`,
2358
- ` sleep ${n("daily_sleep")} readiness ${n("daily_readiness")} activity ${n("daily_activity")} sleep periods ${n("sleep_model")}`,
2359
- ` spo2 ${n("daily_spo2")} stress ${n("daily_stress")} workouts ${n("workouts")} heart rate ${n("heartrate")} cardiovascular age ${n("cardiovascular_age")}`
2360
- ].join(`
2685
+ var SUMMARY_INDENT = 4;
2686
+ var SUMMARY_GAP = 2;
2687
+ var SUMMARY_MAX_COLUMNS = 4;
2688
+ function columnsThatFit(cellWidth, width) {
2689
+ const fits = Math.floor((width - SUMMARY_INDENT + SUMMARY_GAP) / (cellWidth + SUMMARY_GAP));
2690
+ return Math.max(1, Math.min(SUMMARY_MAX_COLUMNS, fits));
2691
+ }
2692
+ function rowsOf(cells, columns) {
2693
+ const rows = [];
2694
+ for (let i = 0;i < cells.length; i += columns) {
2695
+ rows.push(" ".repeat(SUMMARY_INDENT) + cells.slice(i, i + columns).join(" ".repeat(SUMMARY_GAP)).trimEnd());
2696
+ }
2697
+ return rows;
2698
+ }
2699
+ function formatImportSummary(result, width = terminalWidth()) {
2700
+ const counts = COLLECTIONS.map((c) => ({
2701
+ name: c.name,
2702
+ fetched: String(result.fetched[c.table] ?? 0),
2703
+ added: String(result.added[c.table] ?? 0)
2704
+ }));
2705
+ const nameW = Math.max(...counts.map((c) => visibleWidth(c.name)));
2706
+ const fetchedW = Math.max(...counts.map((c) => visibleWidth(c.fetched)));
2707
+ const aligned = counts.map((c) => `${padRight(c.name, nameW)} ${padLeft(c.fetched, fetchedW)} (+${c.added})`);
2708
+ const bare = counts.map((c) => `${c.name} ${c.fetched} (+${c.added})`);
2709
+ const cellW = Math.max(...aligned.map(visibleWidth));
2710
+ const columns = columnsThatFit(cellW, width);
2711
+ const cells = columns === 1 ? bare : aligned.map((c) => padRight(c, cellW));
2712
+ const head = ` Fetched ${result.startDate} \u2192 ${result.endDate}, rows fetched (+new):`;
2713
+ return [head, ...rowsOf(cells, columns)].join(`
2361
2714
  `);
2362
2715
  }
2363
2716
  function formatWeekTable(days, format, emptyHint) {
@@ -2373,7 +2726,7 @@ function formatWeekTable(days, format, emptyHint) {
2373
2726
  }
2374
2727
  const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
2375
2728
  const sep = source_default.gray("\u2500".repeat(56));
2376
- const rows = days.map((d) => `${d.day.padEnd(12)} ${scoreColor(d.sleep_score).padStart(6)} ${scoreColor(d.readiness_score).padStart(6)} ` + `${scoreColor(d.activity_score).padStart(9)} ${String(d.steps ?? "\u2014").padStart(7)} ${(d.stress ?? "\u2014").padEnd(10)}`);
2729
+ const rows = days.map((d) => `${padRight(d.day, 12)} ${padLeft(scoreColor(d.sleep_score), 6)} ${padLeft(scoreColor(d.readiness_score), 6)} ` + `${padLeft(scoreColor(d.activity_score), 9)} ${padLeft(String(d.steps ?? "\u2014"), 7)} ${padRight(d.stress ?? "\u2014", 10)}`);
2377
2730
  return [`
2378
2731
  Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
2379
2732
  `);
@@ -2422,13 +2775,17 @@ function formatStats(stats, format) {
2422
2775
  }
2423
2776
 
2424
2777
  // src/commands/sync.ts
2425
- function resolveWindow(opts) {
2778
+ function resolveWindow(opts, today) {
2426
2779
  if (opts.to !== undefined && opts.from === undefined)
2427
2780
  throw new CliError("BAD_ARGS", "--to requires --from.");
2428
2781
  const from = opts.from === undefined ? undefined : assertCalendarDate(opts.from, "--from");
2429
2782
  const to = opts.to === undefined ? undefined : assertCalendarDate(opts.to, "--to");
2430
2783
  if (from !== undefined && to !== undefined && from > to)
2431
2784
  throw new CliError("BAD_ARGS", `--from (${from}) must not be after --to (${to}).`);
2785
+ const end = to ?? today;
2786
+ if (from !== undefined && from > end) {
2787
+ throw new CliError("BAD_ARGS", `--from (${from}) is after the end of the window (${end}).`, "Pass --to as well to sync a window that ends in the future.");
2788
+ }
2432
2789
  return { from, to };
2433
2790
  }
2434
2791
  async function runSync(ctx, window = {}) {
@@ -2449,7 +2806,7 @@ var syncCommand = dataCommand({
2449
2806
  to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
2450
2807
  },
2451
2808
  needs: { db: true, client: true },
2452
- run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }))
2809
+ run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today))
2453
2810
  });
2454
2811
 
2455
2812
  // src/commands/db.ts
@@ -2519,9 +2876,14 @@ function getReport(db, days, today) {
2519
2876
  const weekStart = shiftDay(today, -(days - 1));
2520
2877
  const prevWeekEnd = shiftDay(today, -days);
2521
2878
  const prevWeekStart = shiftDay(today, -(days * 2 - 1));
2879
+ const windowDays = daysBack(today, days);
2880
+ const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
2881
+ const newestActivityDay = db.query("SELECT MAX(day) AS d FROM daily_activity").get().d;
2882
+ const isComplete = (d) => d < today && newestActivityDay !== null && newestActivityDay > d;
2883
+ const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
2884
+ const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
2522
2885
  const dailyRows = [];
2523
- for (let i = days - 1;i >= 0; i--) {
2524
- const d = shiftDay(today, -i);
2886
+ for (const d of windowDays) {
2525
2887
  const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(d);
2526
2888
  const rd = db.query("SELECT score FROM daily_readiness WHERE day=?").get(d);
2527
2889
  const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(d);
@@ -2531,7 +2893,8 @@ function getReport(db, days, today) {
2531
2893
  sleep: sl?.score ?? null,
2532
2894
  readiness: rd?.score ?? null,
2533
2895
  activity: ac?.score ?? null,
2534
- steps: ac?.steps ?? null
2896
+ steps: ac?.steps ?? null,
2897
+ partial: ac != null && !isComplete(d)
2535
2898
  });
2536
2899
  }
2537
2900
  const metrics = [
@@ -2542,7 +2905,8 @@ function getReport(db, days, today) {
2542
2905
  ];
2543
2906
  const averages = [];
2544
2907
  for (const [label, table, col, isSteps] of metrics) {
2545
- const curr = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as cnt FROM ${table} WHERE day BETWEEN ? AND ?`).get(weekStart, weekEnd);
2908
+ const end = table === "daily_activity" ? activityEnd : weekEnd;
2909
+ const curr = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as cnt FROM ${table} WHERE day BETWEEN ? AND ?`).get(weekStart, end);
2546
2910
  const prev = db.query(`SELECT AVG(${col}) as avg FROM ${table} WHERE day BETWEEN ? AND ?`).get(prevWeekStart, prevWeekEnd);
2547
2911
  if (curr.cnt > 0 && curr.avg !== null) {
2548
2912
  const diff = prev.avg !== null ? curr.avg - prev.avg : null;
@@ -2551,6 +2915,7 @@ function getReport(db, days, today) {
2551
2915
  avg: curr.avg,
2552
2916
  min: curr.min,
2553
2917
  max: curr.max,
2918
+ count: curr.cnt,
2554
2919
  prevAvg: prev.avg,
2555
2920
  diff,
2556
2921
  isSteps
@@ -2561,7 +2926,7 @@ function getReport(db, days, today) {
2561
2926
  const spo2 = sp.cnt > 0 && sp.avg !== null ? { avg: +sp.avg.toFixed(1), min: +sp.min.toFixed(1), max: +sp.max.toFixed(1) } : null;
2562
2927
  const lowSleep = db.query("SELECT day, score FROM daily_sleep WHERE day BETWEEN ? AND ? AND score < 70 ORDER BY score").all(weekStart, weekEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2563
2928
  const lowReadiness = db.query("SELECT day, score FROM daily_readiness WHERE day BETWEEN ? AND ? AND score < 70 ORDER BY score").all(weekStart, weekEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2564
- const highActivity = db.query("SELECT day, score, steps FROM daily_activity WHERE day BETWEEN ? AND ? AND score >= 90 ORDER BY score DESC").all(weekStart, weekEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2929
+ const highActivity = db.query("SELECT day, score, steps FROM daily_activity WHERE day BETWEEN ? AND ? AND score >= 90 ORDER BY score DESC").all(weekStart, activityEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2565
2930
  const sd = db.query(`SELECT AVG(total_sleep_duration) as totalSleep, AVG(deep_sleep_duration) as deepSleep,
2566
2931
  AVG(rem_sleep_duration) as remSleep, AVG(light_sleep_duration) as lightSleep,
2567
2932
  AVG(efficiency) as efficiency, AVG(average_hrv) as hrv, AVG(lowest_heart_rate) as lowestHr
@@ -2570,7 +2935,7 @@ function getReport(db, days, today) {
2570
2935
  const recommendations = [];
2571
2936
  const avgSleep = db.query("SELECT AVG(score) as avg FROM daily_sleep WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2572
2937
  const avgReady = db.query("SELECT AVG(score) as avg FROM daily_readiness WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2573
- const avgSteps = db.query("SELECT AVG(steps) as avg FROM daily_activity WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2938
+ const avgSteps = db.query("SELECT AVG(steps) as avg FROM daily_activity WHERE day BETWEEN ? AND ?").get(weekStart, activityEnd);
2574
2939
  if (avgSleep.avg !== null && avgSleep.avg < 75) {
2575
2940
  recommendations.push("sleep_low");
2576
2941
  } else if (avgSleep.avg !== null && avgSleep.avg >= 85) {
@@ -2586,7 +2951,7 @@ function getReport(db, days, today) {
2586
2951
  } else if (avgSteps.avg !== null && avgSteps.avg >= 1e4) {
2587
2952
  recommendations.push("steps_great");
2588
2953
  }
2589
- return { period, weekStart, weekEnd, days: dailyRows, averages, spo2, patterns: { lowSleep, lowReadiness, highActivity }, sleepDetails, recommendations };
2954
+ return { period, weekStart, weekEnd, days: dailyRows, completeThrough, lastUpload, averages, spo2, patterns: { lowSleep, lowReadiness, highActivity }, sleepDetails, recommendations };
2590
2955
  }
2591
2956
 
2592
2957
  // src/render/format-report.ts
@@ -2600,13 +2965,13 @@ function colorizeScore(n) {
2600
2965
  }
2601
2966
  function scoreCell(n, width) {
2602
2967
  if (n === null)
2603
- return source_default.gray("\u2014".padStart(width));
2604
- return colorizeScore(n)(String(n).padStart(width));
2968
+ return padLeft(source_default.gray("\u2014"), width);
2969
+ return padLeft(colorizeScore(n)(String(n)), width);
2605
2970
  }
2606
2971
  function stepsCell(n, width) {
2607
2972
  if (n === null)
2608
- return source_default.gray("\u2014".padStart(width));
2609
- return n.toLocaleString().padStart(width);
2973
+ return padLeft(source_default.gray("\u2014"), width);
2974
+ return padLeft(n.toLocaleString(), width);
2610
2975
  }
2611
2976
  function fmtSeconds(s) {
2612
2977
  if (s === null)
@@ -2642,11 +3007,20 @@ function bucketDaysIntoWeeks(days) {
2642
3007
  avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
2643
3008
  avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
2644
3009
  avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
2645
- totalSteps: stepsVals.length > 0 ? stepsVals.reduce((a, b) => a + b, 0) : null
3010
+ totalSteps: stepsVals.length > 0 ? stepsVals.reduce((a, b) => a + b, 0) : null,
3011
+ partial: chunk.some((d) => d.partial)
2646
3012
  });
2647
3013
  }
2648
3014
  return buckets;
2649
3015
  }
3016
+ function partialDayNote(data) {
3017
+ const partial = data.days.find((d) => d.partial);
3018
+ if (!partial)
3019
+ return null;
3020
+ const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
3021
+ const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
3022
+ return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
3023
+ }
2650
3024
  function formatReport(data, format, period) {
2651
3025
  if (format === "json")
2652
3026
  return JSON.stringify(data, null, 2);
@@ -2658,6 +3032,9 @@ function formatReport(data, format, period) {
2658
3032
  lines.push(source_default.bold(" Oura Monthly Report"));
2659
3033
  }
2660
3034
  lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
3035
+ const note = partialDayNote(data);
3036
+ if (note)
3037
+ lines.push(source_default.yellow(note));
2661
3038
  lines.push("");
2662
3039
  const hasReportData = data.days.some((day) => day.sleep !== null || day.readiness !== null || day.activity !== null || day.steps !== null) || data.averages.length > 0 || data.spo2 !== null || data.sleepDetails !== null;
2663
3040
  if (!hasReportData) {
@@ -2673,7 +3050,7 @@ function formatReport(data, format, period) {
2673
3050
  lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
2674
3051
  lines.push(source_default.gray(" " + "\u2500".repeat(52)));
2675
3052
  for (const d of data.days) {
2676
- lines.push(` ${d.dayLabel.padEnd(10)} ${scoreCell(d.sleep, 6)} ${scoreCell(d.readiness, 6)} ${scoreCell(d.activity, 7)} ${stepsCell(d.steps, 8)}`);
3053
+ lines.push(` ${(d.partial ? d.dayLabel + "*" : d.dayLabel).padEnd(10)} ${scoreCell(d.sleep, 6)} ${scoreCell(d.readiness, 6)} ${scoreCell(d.activity, 7)} ${stepsCell(d.steps, 8)}`);
2677
3054
  }
2678
3055
  lines.push("");
2679
3056
  } else {
@@ -2686,7 +3063,7 @@ function formatReport(data, format, period) {
2686
3063
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
2687
3064
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
2688
3065
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
2689
- lines.push(` ${b.weekOf.padEnd(12)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
3066
+ lines.push(` ${(b.partial ? b.weekOf + "*" : b.weekOf).padEnd(12)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
2690
3067
  }
2691
3068
  lines.push("");
2692
3069
  }
@@ -2755,7 +3132,7 @@ var reportCommand = dataCommand({
2755
3132
  // src/commands/healthcheck.ts
2756
3133
  function healthcheckCommand(version) {
2757
3134
  return defineCommand({
2758
- meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs})." },
3135
+ meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs}, plus error when ok is false)." },
2759
3136
  args: { ...commonArgs },
2760
3137
  run({ args }) {
2761
3138
  assertKnownArgs(commonArgs, args);
@@ -2874,28 +3251,34 @@ function exitCodeForChecks(checks) {
2874
3251
  return exitCodeFor(new CliError("TOKEN_MISSING", fail.detail));
2875
3252
  return exitCodeFor(new CliError("DB_ERROR", fail.detail));
2876
3253
  }
3254
+ async function runDoctor(ctx, args) {
3255
+ const dbPath = getDbPath(args.db);
3256
+ const deps = {
3257
+ resolveToken: () => resolveToken(args.token),
3258
+ openDb: () => {
3259
+ const db = openDatabase(args.db);
3260
+ ensureSchema(db);
3261
+ return { db, path: dbPath };
3262
+ },
3263
+ createClient: (token) => new OuraClient({ token }),
3264
+ offline: args.offline === true,
3265
+ today: ctx.today
3266
+ };
3267
+ const result = await runChecks(deps);
3268
+ return {
3269
+ json: result,
3270
+ text: () => formatDoctorTable(result),
3271
+ exitCode: exitCodeForChecks(result.checks)
3272
+ };
3273
+ }
2877
3274
  var doctorCommand = dataCommand({
2878
3275
  meta: { name: "doctor", description: "Diagnose token, database, and sync health, and suggest the next step." },
2879
3276
  args: { offline: { type: "boolean", default: false, description: "Skip the live Oura API token-validation call" } },
2880
- async run(ctx, args) {
2881
- const deps = {
2882
- resolveToken: () => resolveToken(args.token),
2883
- openDb: () => {
2884
- const db = openDatabase(args.db);
2885
- ensureSchema(db);
2886
- return { db, path: getDbPath(args.db) };
2887
- },
2888
- createClient: (token) => new OuraClient({ token }),
2889
- offline: args.offline === true,
2890
- today: ctx.today
2891
- };
2892
- const result = await runChecks(deps);
2893
- return {
2894
- json: result,
2895
- text: () => formatDoctorTable(result),
2896
- exitCode: exitCodeForChecks(result.checks)
2897
- };
2898
- }
3277
+ run: (ctx, args) => runDoctor(ctx, {
3278
+ db: args.db,
3279
+ token: args.token,
3280
+ offline: args.offline === true
3281
+ })
2899
3282
  });
2900
3283
 
2901
3284
  // src/commands/manifest.ts
@@ -2919,7 +3302,7 @@ function buildOpenclawManifest(version, commands) {
2919
3302
  examples: EXAMPLES[c.name] ?? [`oura-cli ${c.name}`]
2920
3303
  })),
2921
3304
  envVars: ["OURA_TOKEN", "OURA_TOKEN_PATH", "OURA_DB_PATH", "OURA_TZ"],
2922
- healthcheck: { command: "healthcheck", expects: { ok: "boolean", version: "string", latencyMs: "number" } }
3305
+ healthcheck: { command: "healthcheck", expects: { ok: "boolean", version: "string", latencyMs: "number", error: "string, present only when ok is false" } }
2923
3306
  };
2924
3307
  }
2925
3308
  function manifestCommand(version, getCommands) {
@@ -2957,10 +3340,15 @@ function resolveRange(opts) {
2957
3340
  }
2958
3341
  return { start: opts.today, end: opts.today };
2959
3342
  }
3343
+ function assertRangeAllowed(c, opts) {
3344
+ if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
3345
+ throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
3346
+ }
3347
+ }
2960
3348
  var fetchCommand = dataCommand({
2961
3349
  meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
2962
3350
  args: {
2963
- collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")}` },
3351
+ collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
2964
3352
  day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
2965
3353
  from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
2966
3354
  to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
@@ -2971,13 +3359,14 @@ var fetchCommand = dataCommand({
2971
3359
  const c = byName(args.collection);
2972
3360
  if (!c)
2973
3361
  throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
2974
- const { start, end } = resolveRange({
3362
+ const opts = {
2975
3363
  day: args.day,
2976
3364
  from: args.from,
2977
3365
  to: args.to,
2978
- days: args.days,
2979
- today: ctx.today
2980
- });
3366
+ days: args.days
3367
+ };
3368
+ assertRangeAllowed(c, opts);
3369
+ const { start, end } = resolveRange({ ...opts, today: ctx.today });
2981
3370
  const client = new OuraClient(args.token ? { token: args.token } : {});
2982
3371
  const data = await fetchCollection(client, c, start, end, ctx.tz);
2983
3372
  return { json: data, text: () => JSON.stringify(data, null, 2) };
@@ -2985,12 +3374,12 @@ var fetchCommand = dataCommand({
2985
3374
  });
2986
3375
 
2987
3376
  // src/lib/citty-error.ts
2988
- var ANSI = /\u001b\[[0-9;]*m/g;
3377
+ var ANSI2 = /\u001b\[[0-9;]*m/g;
2989
3378
  function fromCittyError(err, removedCommandHints = {}) {
2990
3379
  const code = err?.code;
2991
3380
  if (typeof code !== "string")
2992
3381
  return err;
2993
- const message = (err instanceof Error ? err.message : String(err)).replace(ANSI, "");
3382
+ const message = (err instanceof Error ? err.message : String(err)).replace(ANSI2, "");
2994
3383
  switch (code) {
2995
3384
  case "E_UNKNOWN_COMMAND": {
2996
3385
  const name = message.replace(/^Unknown command\s*/, "").trim();
@@ -3008,9 +3397,6 @@ function fromCittyError(err, removedCommandHints = {}) {
3008
3397
 
3009
3398
  // src/index.ts
3010
3399
  var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
3011
- if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
3012
- source_default.level = 0;
3013
- }
3014
3400
  var subCommands = Object.assign(Object.create(null), {
3015
3401
  login: loginCommand,
3016
3402
  describe: describeCommand(VERSION, () => subCommands),
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/battery.json",
4
+ "title": "oura-cli fetch battery output",
5
+ "description": "Ring battery level events (percent) with charging state",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "timestamp"
12
+ ],
13
+ "properties": {
14
+ "timestamp": {
15
+ "type": "string",
16
+ "format": "date-time",
17
+ "description": "ISO 8601 timestamp of the event"
18
+ }
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/resilience.json",
4
+ "title": "oura-cli fetch resilience output",
5
+ "description": "Daily resilience level with its sleep-recovery, daytime-recovery and stress contributors",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id",
12
+ "day"
13
+ ],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "description": "Oura record id"
18
+ },
19
+ "day": {
20
+ "type": "string",
21
+ "format": "date",
22
+ "description": "Date the record applies to (YYYY-MM-DD)"
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/rest-mode.json",
4
+ "title": "oura-cli fetch rest-mode output",
5
+ "description": "Rest mode periods; `day` is the period start day, episodes are kept as JSON",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id",
12
+ "start_day"
13
+ ],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "description": "Oura record id"
18
+ },
19
+ "start_day": {
20
+ "type": "string",
21
+ "format": "date",
22
+ "description": "First day of the period (YYYY-MM-DD)"
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/ring.json",
4
+ "title": "oura-cli fetch ring output",
5
+ "description": "Ring hardware, colour, size, firmware and set-up time; a snapshot list, not a day range",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id"
12
+ ],
13
+ "properties": {
14
+ "id": {
15
+ "type": "string",
16
+ "description": "Oura ring id"
17
+ }
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/session.json",
4
+ "title": "oura-cli fetch session output",
5
+ "description": "Guided sessions (meditation, breathing, naps, rest) with their sample series as JSON",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id",
12
+ "day"
13
+ ],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "description": "Oura record id"
18
+ },
19
+ "day": {
20
+ "type": "string",
21
+ "format": "date",
22
+ "description": "Date the session belongs to (YYYY-MM-DD)"
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/sleep-time.json",
4
+ "title": "oura-cli fetch sleep-time output",
5
+ "description": "Suggested bedtime window (offsets in seconds from midnight) with status and recommendation",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id",
12
+ "day"
13
+ ],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "description": "Oura record id"
18
+ },
19
+ "day": {
20
+ "type": "string",
21
+ "format": "date",
22
+ "description": "Date the record applies to (YYYY-MM-DD)"
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/tags.json",
4
+ "title": "oura-cli fetch tags output",
5
+ "description": "Tags the user added in the app; `day` is the tag start day",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id",
12
+ "start_day"
13
+ ],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "description": "Oura record id"
18
+ },
19
+ "start_day": {
20
+ "type": "string",
21
+ "format": "date",
22
+ "description": "Day the tag starts on (YYYY-MM-DD)"
23
+ }
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/drakulavich/oura-cli/blob/main/docs/schemas/vo2max.json",
4
+ "title": "oura-cli fetch vo2max output",
5
+ "description": "Daily VO2 max estimate",
6
+ "type": "array",
7
+ "items": {
8
+ "type": "object",
9
+ "additionalProperties": true,
10
+ "required": [
11
+ "id",
12
+ "day"
13
+ ],
14
+ "properties": {
15
+ "id": {
16
+ "type": "string",
17
+ "description": "Oura record id"
18
+ },
19
+ "day": {
20
+ "type": "string",
21
+ "format": "date",
22
+ "description": "Date the record applies to (YYYY-MM-DD)"
23
+ }
24
+ }
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/oura-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Oura Ring CLI — query and analyze Oura Ring health data from the command line, designed for humans and AI agents.",
5
5
  "keywords": [
6
6
  "oura",