@drakulavich/oura-cli 0.5.2 → 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,26 @@ 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
+
9
29
  ## [0.5.2] - 2026-09-06
10
30
 
11
31
  ### Fixed
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
 
@@ -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
 
@@ -175,7 +188,7 @@ If you're driving the CLI from a script or LLM harness:
175
188
  - Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
176
189
  - Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
177
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.
178
- - Two contract quirks, kept for compatibility: `report --period month` returns its window as `weekStart`/`weekEnd`, and `heartrate.day` in the cache is the date written in Oura's timestamp (UTC in practice) while every `--day`/`--tz` argument is local.
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.
179
192
 
180
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.
181
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) {
@@ -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);
@@ -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 };
@@ -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
@@ -2513,7 +2870,7 @@ function dayLabel(dateStr) {
2513
2870
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2514
2871
  return `${day} ${dd}/${mm}`;
2515
2872
  }
2516
- function getReport(db, days, today, tz = "UTC") {
2873
+ function getReport(db, days, today) {
2517
2874
  const period = days <= 7 ? "week" : "month";
2518
2875
  const weekEnd = today;
2519
2876
  const weekStart = shiftDay(today, -(days - 1));
@@ -2521,7 +2878,8 @@ function getReport(db, days, today, tz = "UTC") {
2521
2878
  const prevWeekStart = shiftDay(today, -(days * 2 - 1));
2522
2879
  const windowDays = daysBack(today, days);
2523
2880
  const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
2524
- const isComplete = (d) => d < today && (lastUpload === null || Date.parse(localDateToUtcRange(d, tz)[1]) <= Date.parse(lastUpload));
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;
2525
2883
  const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
2526
2884
  const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
2527
2885
  const dailyRows = [];
@@ -2607,13 +2965,13 @@ function colorizeScore(n) {
2607
2965
  }
2608
2966
  function scoreCell(n, width) {
2609
2967
  if (n === null)
2610
- return source_default.gray("\u2014".padStart(width));
2611
- return colorizeScore(n)(String(n).padStart(width));
2968
+ return padLeft(source_default.gray("\u2014"), width);
2969
+ return padLeft(colorizeScore(n)(String(n)), width);
2612
2970
  }
2613
2971
  function stepsCell(n, width) {
2614
2972
  if (n === null)
2615
- return source_default.gray("\u2014".padStart(width));
2616
- return n.toLocaleString().padStart(width);
2973
+ return padLeft(source_default.gray("\u2014"), width);
2974
+ return padLeft(n.toLocaleString(), width);
2617
2975
  }
2618
2976
  function fmtSeconds(s) {
2619
2977
  if (s === null)
@@ -2655,18 +3013,15 @@ function bucketDaysIntoWeeks(days) {
2655
3013
  }
2656
3014
  return buckets;
2657
3015
  }
2658
- function partialDayNote(data, tz) {
2659
- const partial = data.days.filter((d) => d.partial);
2660
- if (partial.length === 0)
3016
+ function partialDayNote(data) {
3017
+ const partial = data.days.find((d) => d.partial);
3018
+ if (!partial)
2661
3019
  return null;
2662
- const newest = partial[partial.length - 1];
2663
- const newestLabel = newest.day === data.weekEnd ? "today" : newest.dayLabel;
2664
- const which = partial.length === 1 ? `${newestLabel} is` : `${newestLabel} and ${partial.length - 1} earlier day${partial.length > 2 ? "s" : ""} are`;
2665
- const synced = data.lastUpload ? `ring last synced ${formatLocal(data.lastUpload, tz)}` : "ring sync time unknown";
3020
+ const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
2666
3021
  const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
2667
- return ` * ${which} still accumulating (${synced}); activity averages cover ${covers}.`;
3022
+ return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
2668
3023
  }
2669
- function formatReport(data, format, period, tz = "UTC") {
3024
+ function formatReport(data, format, period) {
2670
3025
  if (format === "json")
2671
3026
  return JSON.stringify(data, null, 2);
2672
3027
  const lines = [];
@@ -2677,7 +3032,7 @@ function formatReport(data, format, period, tz = "UTC") {
2677
3032
  lines.push(source_default.bold(" Oura Monthly Report"));
2678
3033
  }
2679
3034
  lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
2680
- const note = partialDayNote(data, tz);
3035
+ const note = partialDayNote(data);
2681
3036
  if (note)
2682
3037
  lines.push(source_default.yellow(note));
2683
3038
  lines.push("");
@@ -2769,8 +3124,8 @@ var reportCommand = dataCommand({
2769
3124
  if (period !== "week" && period !== "month") {
2770
3125
  throw new CliError("BAD_ARGS", `--period must be "week" or "month", got "${period}".`);
2771
3126
  }
2772
- const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today, ctx.tz);
2773
- return { json: data, text: () => formatReport(data, "table", period, ctx.tz) };
3127
+ const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today);
3128
+ return { json: data, text: () => formatReport(data, "table", period) };
2774
3129
  }
2775
3130
  });
2776
3131
 
@@ -2896,28 +3251,34 @@ function exitCodeForChecks(checks) {
2896
3251
  return exitCodeFor(new CliError("TOKEN_MISSING", fail.detail));
2897
3252
  return exitCodeFor(new CliError("DB_ERROR", fail.detail));
2898
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
+ }
2899
3274
  var doctorCommand = dataCommand({
2900
3275
  meta: { name: "doctor", description: "Diagnose token, database, and sync health, and suggest the next step." },
2901
3276
  args: { offline: { type: "boolean", default: false, description: "Skip the live Oura API token-validation call" } },
2902
- async run(ctx, args) {
2903
- const deps = {
2904
- resolveToken: () => resolveToken(args.token),
2905
- openDb: () => {
2906
- const db = openDatabase(args.db);
2907
- ensureSchema(db);
2908
- return { db, path: getDbPath(args.db) };
2909
- },
2910
- createClient: (token) => new OuraClient({ token }),
2911
- offline: args.offline === true,
2912
- today: ctx.today
2913
- };
2914
- const result = await runChecks(deps);
2915
- return {
2916
- json: result,
2917
- text: () => formatDoctorTable(result),
2918
- exitCode: exitCodeForChecks(result.checks)
2919
- };
2920
- }
3277
+ run: (ctx, args) => runDoctor(ctx, {
3278
+ db: args.db,
3279
+ token: args.token,
3280
+ offline: args.offline === true
3281
+ })
2921
3282
  });
2922
3283
 
2923
3284
  // src/commands/manifest.ts
@@ -2979,10 +3340,15 @@ function resolveRange(opts) {
2979
3340
  }
2980
3341
  return { start: opts.today, end: opts.today };
2981
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
+ }
2982
3348
  var fetchCommand = dataCommand({
2983
3349
  meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
2984
3350
  args: {
2985
- 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)` },
2986
3352
  day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
2987
3353
  from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
2988
3354
  to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
@@ -2993,13 +3359,14 @@ var fetchCommand = dataCommand({
2993
3359
  const c = byName(args.collection);
2994
3360
  if (!c)
2995
3361
  throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
2996
- const { start, end } = resolveRange({
3362
+ const opts = {
2997
3363
  day: args.day,
2998
3364
  from: args.from,
2999
3365
  to: args.to,
3000
- days: args.days,
3001
- today: ctx.today
3002
- });
3366
+ days: args.days
3367
+ };
3368
+ assertRangeAllowed(c, opts);
3369
+ const { start, end } = resolveRange({ ...opts, today: ctx.today });
3003
3370
  const client = new OuraClient(args.token ? { token: args.token } : {});
3004
3371
  const data = await fetchCollection(client, c, start, end, ctx.tz);
3005
3372
  return { json: data, text: () => JSON.stringify(data, null, 2) };
@@ -3007,12 +3374,12 @@ var fetchCommand = dataCommand({
3007
3374
  });
3008
3375
 
3009
3376
  // src/lib/citty-error.ts
3010
- var ANSI = /\u001b\[[0-9;]*m/g;
3377
+ var ANSI2 = /\u001b\[[0-9;]*m/g;
3011
3378
  function fromCittyError(err, removedCommandHints = {}) {
3012
3379
  const code = err?.code;
3013
3380
  if (typeof code !== "string")
3014
3381
  return err;
3015
- const message = (err instanceof Error ? err.message : String(err)).replace(ANSI, "");
3382
+ const message = (err instanceof Error ? err.message : String(err)).replace(ANSI2, "");
3016
3383
  switch (code) {
3017
3384
  case "E_UNKNOWN_COMMAND": {
3018
3385
  const name = message.replace(/^Unknown command\s*/, "").trim();
@@ -3030,9 +3397,6 @@ function fromCittyError(err, removedCommandHints = {}) {
3030
3397
 
3031
3398
  // src/index.ts
3032
3399
  var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
3033
- if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
3034
- source_default.level = 0;
3035
- }
3036
3400
  var subCommands = Object.assign(Object.create(null), {
3037
3401
  login: loginCommand,
3038
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.2",
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",