@drakulavich/oura-cli 0.7.1 → 0.8.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,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.8.0] - 2026-09-13
10
+
11
+ ### Added
12
+ - `db rows <collection>` prints the cached rows of any collection, as stored, in both output modes: the cache-side twin of `fetch`, with the same range flags and defaults (`--day`, `--from/--to`, `--days`; today by default; none for `ring`). Eight of the seventeen collections were write-only from the user's side: `sync` filled resilience, VO₂ max, bedtime guidance, sessions, rest mode, tags, ring and battery, `db stats` counted them, and nothing read them back short of `fetch`, which goes to the API and ignores the cache. The table view shows one column per stored column, numbers right-aligned, text cells cut at 40 characters and, on a terminal, squeezed further so the table fits the screen; a column name is never cut, and when the names alone would not fit (sleep-periods has 19 columns) each row is printed as a block of `name value` lines instead. `--limit N` prints the first N rows and the title says how many the range holds; a day of heart rate is hundreds. A newline in a tag comment shows as ⏎ instead of breaking the row. The JSON is the rows exactly as stored, so a JSON blob such as `contributors` stays a string. Heart-rate and battery samples are bounded on the local day asked for, the way `fetch` bounds its request, not on the UTC date their `day` column stores. An empty result names the range asked for and how to fill it. The `describe` manifest lists the subcommand with the collection enum. (#73)
13
+
14
+ ### Fixed
15
+ - `doctor` no longer calls a cache current while `report` and `db week` mark its newest day as still accumulating. The `data` check compared calendar days and only warned past two, so on 2026-09-12 a cache ending on 2026-09-10 passed. It now measures hours from the end of the newest day (its local midnight in the cache timezone) and warns past 36: one missed night is ordinary ring lag, a second is worth the nudge. The detail names the limit, and `nextStep` points at `oura-cli sync`. (#114)
16
+ - Text output is tidier. Every view draws its horizontal rule at the same two-column indent as the text under it (`db today`, `db week`, `db trends`, `db stats`, `doctor` and the panel after `sync` drew theirs at column 0), as wide as the widest line it delimits (a fixed 50 sat under 63-column trend rows), and never wider than the terminal, where it wrapped onto a second line. `db week` no longer pads its last column, which left up to nine trailing spaces on every row. The empty-today panel after `sync` explains that Oura publishes a day's summary once the night's sleep has synced, as `db today` already did, instead of printing bare dashes. `oura-cli db` on its own, `db toady`, and `db --format json` with no subcommand name the subcommands `db` takes and point at `oura-cli db --help` rather than the root help; on a terminal a bare `oura-cli db` prints that help, as a bare `oura-cli` does. (#61)
17
+ - A `429 Too Many Requests` no longer ends a long `fetch` or `sync` on the spot. The page is retried up to three times after the wait `Retry-After` asks for (seconds or an HTTP date, each wait capped at one minute), or one, two and four seconds without it, and one command waits at most three minutes on 429 answers in total; only then is it an `API_ERROR`, with a hint that says how often the page was retried, how long the command waited, and to try again later or ask for a shorter range. A transport failure (DNS, connection refused) is an `API_ERROR` with a hint too, where it was an `UNKNOWN` with none. On a terminal, `fetch` and `sync` show a one-line progress counter on stderr (`fetching heartrate: page 41, 40000 rows so far…`), which also says when the client is waiting on a 429 (`; rate limited, retrying in 30 s…`) and is wiped when the command finishes; a 61-day `fetch hr` is 77 pages and was over a minute of silence, and a rate-limit wait was the same silence. On a pipe stderr stays clean, since it carries the error envelope. `doctor` names a next step when the API could not be reached, where it reported `ok: false` with `nextStep: null`. No upper bound was put on the range: a long backfill is a legitimate use, and pacing rather than refusing is what the API asks for. (#45)
18
+ - A malformed response body from the Oura API is now an `API_ERROR` (exit 3) naming the endpoint and what came back, instead of an `UNKNOWN` with an internal expression. `sync` and `fetch` walked whatever sat under `data`: an object failed with `{} is not iterable`, and a string was iterated character by character and reported as dropped samples that never existed. A missing or null `data` is still an empty page; a `next_token` that is not a string or null is the same error, not a last page. (#112)
19
+ - `db date` and `db today` mark a day whose activity totals are not final the way `db week` and `report` already do: `2026-09-10*` in the header and one note under the numbers. The JSON has carried `partial` since 0.7.1; the table output did not show it, so drilling from a marked week row into the day lost the mark. The note's wording is now one string shared by the day and week views. (#113)
20
+ - Small text inconsistencies from the eight-collection expansion. `db stats` names each table by both its collection name and its table (`sleep-periods (sleep_model)`), as the `sync` lines already did, and prints `1 row`, not `1 rows`; its JSON rows carry a `collection` field. `db stats` explains itself when the cache holds rows but no daily summaries, instead of printing counts and nothing else. The `*` note in `report` says "activity totals are not final" like the day and week views, which reads right when the marked day is in the past. `sync --help` describes what the command prints; the unknown-flag hint no longer claims there are no single-letter flags when `-v` and `-h` exist; README names the identity fields the per-collection schemas actually pin. (#72)
21
+ - A `sync` response that includes a row from outside the window it was asked for no longer widens that request's reconciliation scope. The scope of one request was taken from the timestamps that came back, so a single early or empty-string timestamp reached back over every stored sample between it and the window: on a 60-sample cache one stray value put 59 rows up as "not returned", with the truncation guard's own message naming the `--prune=hr` flag that would have deleted them. Each request now carries the range it asked for and only rows inside it count towards the scope; rows outside are still stored, they just vouch for nothing. (#111)
22
+
9
23
  ## [0.7.1] - 2026-09-12
10
24
 
11
25
  ### Fixed
package/README.md CHANGED
@@ -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. A day whose activity is still accumulating — normally just today — is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`. `db today`, `db date` and `db week` carry the same `partial` flag, and `db week` marks the day with a `*` too, so the two screens cannot disagree. A day is treated as closed once Oura reports a full 24 hours of five-minute activity slots for it, so a ring that stops syncing no longer freezes its last day as unfinished.
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 — normally just today — is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`. `db today`, `db date` and `db week` carry the same `partial` flag and mark the day with the same `*` and note, so the screens cannot disagree. A day is treated as closed once Oura reports a full 24 hours of five-minute activity slots for it, so a ring that stops syncing no longer freezes its last day as unfinished.
100
100
 
101
101
  ### Trends and stats
102
102
 
@@ -105,6 +105,16 @@ oura-cli db trends 30 # score trends across the last 30 days
105
105
  oura-cli db stats # row counts, date range, personal bests
106
106
  ```
107
107
 
108
+ ### Cached rows of any collection
109
+
110
+ `db rows` prints what the cache holds for one collection, as stored, in either output mode. It takes the same range flags and defaults as `fetch` (`--day`, `--from/--to`, `--days`; today by default; none for `ring`), so the two are twins: `fetch` reads the API, `db rows` reads the cache. This is how the collections no summary shows — tags, sessions, resilience, VO₂ max, bedtime guidance, rest mode, ring, battery — are read back.
111
+
112
+ ```bash
113
+ oura-cli db rows tags --days 30 # your own annotations for the month
114
+ oura-cli db rows battery --day 2026-09-01 # the battery curve for one day
115
+ oura-cli db rows ring --format json # every ring on the account, as JSON
116
+ ```
117
+
108
118
  ### Raw API records
109
119
 
110
120
  `fetch` returns one collection straight from the Oura API as JSON, without touching the local cache.
@@ -187,7 +197,7 @@ If you're driving the CLI from a script or LLM harness:
187
197
  - 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`.
188
198
  - Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
189
199
  - Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
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.
200
+ - 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 each collection's identity fields (`id` and `day` for the daily summaries, `timestamp` for the heart-rate and battery series, `id` and `start_day` for rest-mode and tags, `id` alone for ring) 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
201
  - 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.
192
202
 
193
203
  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.
package/dist/index.js CHANGED
@@ -1534,11 +1534,46 @@ function resolveToken(explicit, tokenPath) {
1534
1534
 
1535
1535
  // src/api/client.ts
1536
1536
  var BASE_URL = "https://api.ouraring.com/v2/usercollection";
1537
+ function kindOf(value) {
1538
+ if (value === null)
1539
+ return "null";
1540
+ if (Array.isArray(value))
1541
+ return "an array";
1542
+ return typeof value === "object" ? "an object" : `a ${typeof value}`;
1543
+ }
1537
1544
  var MAX_PAGES = 1e4;
1545
+ var RETRY_LIMIT = 3;
1546
+ var MAX_RETRY_AFTER_MS = 60000;
1547
+ var MAX_TOTAL_WAIT_MS = 3 * 60000;
1548
+ var BACKOFF_MS = [1000, 2000, 4000];
1549
+ function retryDelayMs(retryAfter, attempt, now = Date.now()) {
1550
+ let ms = Number.NaN;
1551
+ if (retryAfter !== null) {
1552
+ const whole = retryAfter.trim();
1553
+ const firstPart = whole.split(",")[0].trim();
1554
+ const firstDate = whole.match(/^[A-Za-z]{3}, [^,]*? GMT/)?.[0] ?? "";
1555
+ const seconds = [whole, firstPart].filter((r) => r !== "").map(Number).find(Number.isFinite);
1556
+ const date = [whole, firstDate, firstPart].map(Date.parse).find(Number.isFinite);
1557
+ if (seconds !== undefined)
1558
+ ms = seconds * 1000;
1559
+ else if (date !== undefined)
1560
+ ms = date - now;
1561
+ }
1562
+ if (!Number.isFinite(ms) || ms <= 0)
1563
+ ms = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
1564
+ return Math.min(ms, MAX_RETRY_AFTER_MS);
1565
+ }
1538
1566
 
1539
1567
  class OuraClient {
1540
1568
  token;
1569
+ onPage;
1570
+ onRetry;
1571
+ sleep;
1572
+ waitedMs = 0;
1541
1573
  constructor(options = {}) {
1574
+ this.onPage = options.onPage;
1575
+ this.onRetry = options.onRetry;
1576
+ this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1542
1577
  const { token, source } = resolveToken(options.token, options.tokenPath);
1543
1578
  if (!token) {
1544
1579
  throw new CliError("TOKEN_MISSING", `No Oura access token at ${source}.`, "Run `oura-cli login` or set OURA_TOKEN.");
@@ -1559,7 +1594,8 @@ class OuraClient {
1559
1594
  const params = new URLSearchParams(query);
1560
1595
  if (nextToken)
1561
1596
  params.set("next_token", nextToken);
1562
- const page = await this.getPage(`${BASE_URL}/${endpoint}?${params}`);
1597
+ const page = await this.getPage(endpoint, `${BASE_URL}/${endpoint}?${params}`);
1598
+ this.onPage?.({ endpoint, rows: page.data.length });
1563
1599
  for (const row of page.data)
1564
1600
  rows.push(row);
1565
1601
  nextToken = page.next_token;
@@ -1571,17 +1607,38 @@ class OuraClient {
1571
1607
  } while (nextToken);
1572
1608
  return rows;
1573
1609
  }
1574
- async getPage(url) {
1575
- const response = await fetch(url, {
1576
- headers: { Authorization: `Bearer ${this.token}` }
1577
- });
1610
+ async request(url) {
1611
+ try {
1612
+ return await fetch(url, { headers: { Authorization: `Bearer ${this.token}` } });
1613
+ } catch (err) {
1614
+ const msg = err instanceof Error ? err.message : String(err);
1615
+ throw new CliError("API_ERROR", `Could not reach the Oura API: ${msg}`, "Check the network connection and try again.");
1616
+ }
1617
+ }
1618
+ async getPage(endpoint, url) {
1619
+ let response = await this.request(url);
1620
+ let retries = 0;
1621
+ for (;response.status === 429 && retries < RETRY_LIMIT; retries++) {
1622
+ const waitMs = retryDelayMs(response.headers.get("retry-after"), retries);
1623
+ if (this.waitedMs + waitMs > MAX_TOTAL_WAIT_MS)
1624
+ break;
1625
+ await response.body?.cancel();
1626
+ this.waitedMs += waitMs;
1627
+ this.onRetry?.({ endpoint, waitMs, attempt: retries });
1628
+ await this.sleep(waitMs);
1629
+ response = await this.request(url);
1630
+ }
1578
1631
  if (!response.ok) {
1579
1632
  const rawBody = await response.text();
1580
- const redacted = redactSecrets(rawBody);
1633
+ const redacted = redactSecrets(rawBody).split(this.token).join("[REDACTED]");
1581
1634
  const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
1582
1635
  if (response.status === 401 || response.status === 403) {
1583
1636
  throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`, "Run `oura-cli login` with a fresh Personal Access Token, or check OURA_TOKEN.");
1584
1637
  }
1638
+ if (response.status === 429) {
1639
+ const waited = Math.round(this.waitedMs / 1000);
1640
+ throw new CliError("API_ERROR", `Oura API 429: ${body}`, `Rate limited; this page was retried ${retries} time${retries === 1 ? "" : "s"} and the command has waited ${waited} s on 429 answers (the most it will is ${MAX_TOTAL_WAIT_MS / 1000} s). Wait a few minutes and run it again, or ask for a shorter range.`);
1641
+ }
1585
1642
  throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
1586
1643
  }
1587
1644
  let json;
@@ -1591,6 +1648,15 @@ class OuraClient {
1591
1648
  throw new CliError("API_ERROR", "Empty response body from Oura API.");
1592
1649
  }
1593
1650
  const body = json;
1651
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
1652
+ throw new CliError("API_ERROR", `Oura API returned a malformed body for ${endpoint}: expected an object, got ${kindOf(body)}.`);
1653
+ }
1654
+ if (body.data != null && !Array.isArray(body.data)) {
1655
+ throw new CliError("API_ERROR", `Oura API returned a malformed body for ${endpoint}: expected an array under "data", got ${kindOf(body.data)}.`);
1656
+ }
1657
+ if (body.next_token != null && typeof body.next_token !== "string") {
1658
+ throw new CliError("API_ERROR", `Oura API returned a malformed body for ${endpoint}: expected a string or null under "next_token", got ${kindOf(body.next_token)}.`);
1659
+ }
1594
1660
  return { data: body.data ?? [], next_token: body.next_token ?? null };
1595
1661
  }
1596
1662
  }
@@ -1685,6 +1751,45 @@ function formatFromArgv(argv, isTty) {
1685
1751
  }
1686
1752
  }
1687
1753
 
1754
+ // src/lib/progress.ts
1755
+ function pageProgress(sink, verb) {
1756
+ let endpoint;
1757
+ let pages = 0;
1758
+ let rows = 0;
1759
+ let widest = 0;
1760
+ let written = false;
1761
+ const show = (text) => {
1762
+ widest = Math.max(widest, text.length);
1763
+ written = true;
1764
+ sink.write(`\r${text}`);
1765
+ };
1766
+ const status = () => pages === 0 ? ` ${verb} ${endpoint}` : ` ${verb} ${endpoint}: page ${pages}, ${rows} rows so far`;
1767
+ return {
1768
+ onPage(page) {
1769
+ if (page.endpoint !== endpoint) {
1770
+ endpoint = page.endpoint;
1771
+ pages = 0;
1772
+ rows = 0;
1773
+ }
1774
+ pages++;
1775
+ rows += page.rows;
1776
+ show(`${status()}\u2026`);
1777
+ },
1778
+ onRetry(retry) {
1779
+ if (retry.endpoint !== endpoint) {
1780
+ endpoint = retry.endpoint;
1781
+ pages = 0;
1782
+ rows = 0;
1783
+ }
1784
+ show(`${status()}; rate limited, retrying in ${Math.ceil(retry.waitMs / 1000)} s\u2026`);
1785
+ },
1786
+ done() {
1787
+ if (written)
1788
+ sink.write(`\r${" ".repeat(widest)}\r`);
1789
+ }
1790
+ };
1791
+ }
1792
+
1688
1793
  // src/lib/time.ts
1689
1794
  function nowUtc() {
1690
1795
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -1794,7 +1899,8 @@ var processIo = {
1794
1899
  `);
1795
1900
  },
1796
1901
  exit: (code) => process.exit(code),
1797
- isTty: process.stdout.isTTY === true
1902
+ isTty: process.stdout.isTTY === true,
1903
+ progress: process.stderr.isTTY ? process.stderr : undefined
1798
1904
  };
1799
1905
  var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1800
1906
  function assertKnownArgs(declared, args) {
@@ -1815,7 +1921,7 @@ function assertKnownArgs(declared, args) {
1815
1921
  const unknown = Object.keys(args).filter((k) => !known.has(k));
1816
1922
  if (unknown.length > 0) {
1817
1923
  const flags = unknown.map((f) => f.length === 1 ? `-${f}` : `--${f}`).join(", ");
1818
- const hint = unknown.every((f) => f.length === 1) ? 'oura-cli has no single-letter flags; a value that starts with "-" must come after "--".' : "Run the command with --help to see its flags.";
1924
+ const hint = unknown.every((f) => f.length === 1) ? 'oura-cli has no single-letter flags other than -v and -h; a value that starts with "-" must come after "--".' : "Run the command with --help to see its flags.";
1819
1925
  throw new CliError("BAD_ARGS", `Unknown flag${unknown.length > 1 ? "s" : ""}: ${flags}.`, hint);
1820
1926
  }
1821
1927
  const extra = (args._ ?? []).slice(positionals);
@@ -1834,16 +1940,25 @@ async function execute(def, args, io = processIo) {
1834
1940
  assertKnownArgs({ ...commonArgs, ...def.args ?? {} }, args);
1835
1941
  const outputFormat = def.jsonOnly ? "json" : format;
1836
1942
  const tz = assertTimezone(args.tz ?? resolveDefaultTimezone());
1837
- const ctx = { format: outputFormat, tz, today: today(tz) };
1943
+ const ctx = { format: outputFormat, tz, today: today(tz), ...io.progress ? { progress: io.progress } : {} };
1838
1944
  if (def.needs?.db) {
1839
1945
  db = openDatabase(args.db);
1840
1946
  ensureSchema(db);
1841
1947
  ctx.db = db;
1842
1948
  }
1949
+ const progress = def.needs?.client && io.progress ? pageProgress(io.progress, "syncing") : undefined;
1843
1950
  if (def.needs?.client) {
1844
- ctx.client = new OuraClient(args.token ? { token: args.token } : {});
1951
+ ctx.client = new OuraClient({
1952
+ ...args.token ? { token: args.token } : {},
1953
+ ...progress ? { onPage: progress.onPage, onRetry: progress.onRetry } : {}
1954
+ });
1955
+ }
1956
+ let out;
1957
+ try {
1958
+ out = await def.run(ctx, args);
1959
+ } finally {
1960
+ progress?.done();
1845
1961
  }
1846
- const out = await def.run(ctx, args);
1847
1962
  io.stdout(outputFormat === "json" ? JSON.stringify(out.json, null, 2) : out.text());
1848
1963
  exitCode = out.exitCode ?? 0;
1849
1964
  } catch (raw) {
@@ -2442,18 +2557,19 @@ function rangeQueries(c, start, end, tz) {
2442
2557
  async function fetchCollectionByPiece(client, c, start, end, tz) {
2443
2558
  const pieces = [];
2444
2559
  for (const query of rangeQueries(c, start, end, tz)) {
2445
- pieces.push(await client.fetch(c.endpoint, query));
2560
+ pieces.push({ query, rows: await client.fetch(c.endpoint, query) });
2446
2561
  }
2447
2562
  return pieces;
2448
2563
  }
2449
2564
  async function fetchCollection(client, c, start, end, tz) {
2450
- return (await fetchCollectionByPiece(client, c, start, end, tz)).flat();
2565
+ return (await fetchCollectionByPiece(client, c, start, end, tz)).flatMap((p) => p.rows);
2451
2566
  }
2452
2567
 
2453
2568
  // src/commands/describe.ts
2454
2569
  var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json", describe: "docs/schemas/describe.json" };
2455
2570
  var ENUM_ARGS = {
2456
2571
  fetch: { collection: names() },
2572
+ rows: { collection: names() },
2457
2573
  report: { period: ["week", "month"] }
2458
2574
  };
2459
2575
  function resolved(def) {
@@ -2556,6 +2672,19 @@ var KEY_SEPARATOR = "\x00";
2556
2672
  function emptyPlan() {
2557
2673
  return { added: 0, stale: [], refused: 0, bypassed: 0 };
2558
2674
  }
2675
+ function requestedRange(c, query) {
2676
+ if (c.rangeParams === "datetime") {
2677
+ const from = Date.parse(query.start_datetime ?? "");
2678
+ const to = Date.parse(query.end_datetime ?? "");
2679
+ return (value) => {
2680
+ const t = Date.parse(value);
2681
+ return t >= from && t <= to;
2682
+ };
2683
+ }
2684
+ const from = query.start_date;
2685
+ const to = query.end_date;
2686
+ return (value) => from !== undefined && to !== undefined && value >= from && value <= to;
2687
+ }
2559
2688
  function keyOf(values) {
2560
2689
  return values.map((v) => String(v)).join(KEY_SEPARATOR);
2561
2690
  }
@@ -2570,12 +2699,13 @@ function planWindow(db, c, pieces, options = {}) {
2570
2699
  if (scopePick === undefined || identityPicks.some((p) => p === undefined))
2571
2700
  return emptyPlan();
2572
2701
  const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
2573
- const wanted = new Set(pieces.flat().map(keyFor));
2702
+ const wanted = new Set(pieces.flatMap((p) => p.rows).map(keyFor));
2574
2703
  const plan = emptyPlan();
2575
2704
  const judged = pieces.map((piece) => {
2576
- if (piece.length === 0)
2705
+ if (piece.rows.length === 0)
2577
2706
  return null;
2578
- const scopeValues = piece.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String);
2707
+ const inRequested = requestedRange(c, piece.query);
2708
+ const scopeValues = piece.rows.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String).filter(inRequested);
2579
2709
  if (scopeValues.length === 0)
2580
2710
  return null;
2581
2711
  const days = [...new Set(scopeValues)];
@@ -2591,7 +2721,7 @@ function planWindow(db, c, pieces, options = {}) {
2591
2721
  stale.push(values);
2592
2722
  }
2593
2723
  const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
2594
- const fresh = [...new Set(piece.map(keyFor))].filter((key) => !storedKeys.has(key));
2724
+ const fresh = [...new Set(piece.rows.map(keyFor))].filter((key) => !storedKeys.has(key));
2595
2725
  return { stale, looksTruncated, fresh };
2596
2726
  });
2597
2727
  const doubted = new Set;
@@ -2677,9 +2807,9 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2677
2807
  const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
2678
2808
  for (const { c, start } of plan) {
2679
2809
  const returned = await fetchCollectionByPiece(client, c, start, end, tz);
2680
- const pieces = returned.map((piece) => piece.filter((r) => hasIdentity(c, r)));
2681
- const rows = pieces.flat();
2682
- const missing = returned.flat().length - rows.length;
2810
+ const pieces = returned.map((piece) => ({ ...piece, rows: piece.rows.filter((r) => hasIdentity(c, r)) }));
2811
+ const rows = pieces.flatMap((p) => p.rows);
2812
+ const missing = returned.flatMap((p) => p.rows).length - rows.length;
2683
2813
  if (missing > 0)
2684
2814
  dropped[c.table] = missing;
2685
2815
  const droppedTail = missing > 0 ? `, ${missing} dropped (no ${identityColumns(c).join("/")})` : "";
@@ -2717,7 +2847,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2717
2847
  const { windowPlan, gone } = db.transaction((ps) => {
2718
2848
  const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
2719
2849
  for (const piece of ps)
2720
- for (const r of piece)
2850
+ for (const r of piece.rows)
2721
2851
  stmt.run(...rowValues(c, r));
2722
2852
  return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
2723
2853
  }).immediate(pieces);
@@ -2821,7 +2951,7 @@ function getTrends(db, days, today) {
2821
2951
  function getStats(db, today) {
2822
2952
  const tables = COLLECTIONS.map((c) => {
2823
2953
  const row = db.query(`SELECT COUNT(*) as cnt FROM ${c.table}`).get();
2824
- return { table: c.table, rows: row.cnt };
2954
+ return { collection: c.name, table: c.table, rows: row.cnt };
2825
2955
  });
2826
2956
  const range = db.query("SELECT MIN(day) as first, MAX(day) as last FROM daily_sleep").get();
2827
2957
  const trends = getTrends(db, 99999, today);
@@ -2857,7 +2987,28 @@ function padRight(text, width) {
2857
2987
 
2858
2988
  // src/lib/terminal.ts
2859
2989
  function terminalWidth() {
2860
- return process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : 80;
2990
+ return screenWidth() ?? 80;
2991
+ }
2992
+ function screenWidth() {
2993
+ return process.stdout.isTTY && process.stdout.columns ? process.stdout.columns : undefined;
2994
+ }
2995
+
2996
+ // src/render/rule.ts
2997
+ init_source();
2998
+ var INDENT = " ";
2999
+ function rule(width, glyph = "\u2500", max = screenWidth()) {
3000
+ const drawn = max === undefined ? width : Math.max(0, Math.min(width, max - INDENT.length));
3001
+ return drawn === 0 ? "" : source_default.gray(INDENT + glyph.repeat(drawn));
3002
+ }
3003
+ var PLACEHOLDER = "<rule ";
3004
+ var RULE = `${PLACEHOLDER}\u2500>`;
3005
+ var DOUBLE_RULE = `${PLACEHOLDER}\u2550>`;
3006
+ function finish(lines, max = screenWidth()) {
3007
+ let widest = 0;
3008
+ for (const l of lines)
3009
+ if (!l.startsWith(PLACEHOLDER))
3010
+ widest = Math.max(widest, visibleWidth(l) - INDENT.length);
3011
+ return lines.map((l) => l.startsWith(PLACEHOLDER) ? rule(widest, l.slice(PLACEHOLDER.length, -1), max) : l);
2861
3012
  }
2862
3013
 
2863
3014
  // src/render/format.ts
@@ -2882,19 +3033,19 @@ function formatDaySummary(summary, format, emptyHint) {
2882
3033
  if (format === "json")
2883
3034
  return JSON.stringify(summary, null, 2);
2884
3035
  if (emptyHint && isEmptyDay(summary)) {
2885
- return [
3036
+ return finish([
2886
3037
  "",
2887
3038
  source_default.bold(` ${summary.day}`),
2888
- source_default.gray("\u2500".repeat(50)),
3039
+ RULE,
2889
3040
  ` No Oura data for ${summary.day} yet.`,
2890
3041
  ` ${emptyHint}`
2891
- ].join(`
3042
+ ]).join(`
2892
3043
  `);
2893
3044
  }
2894
3045
  const lines = [
2895
3046
  "",
2896
- source_default.bold(` ${summary.day}`),
2897
- source_default.gray("\u2500".repeat(50)),
3047
+ source_default.bold(` ${summary.partial ? `${summary.day}*` : summary.day}`),
3048
+ RULE,
2898
3049
  ` Sleep: ${scoreColor(summary.sleep_score)} Readiness: ${scoreColor(summary.readiness_score)} Activity: ${scoreColor(summary.activity_score)}`,
2899
3050
  ` Steps: ${summary.steps ?? source_default.gray("\u2014")}`
2900
3051
  ];
@@ -2911,9 +3062,13 @@ function formatDaySummary(summary, format, emptyHint) {
2911
3062
  lines.push(` Sleep: ${fmtHours(summary.sleep_hours)} total | ${fmtHours(summary.deep_hours)} deep | ${fmtHours(summary.rem_hours)} REM`);
2912
3063
  lines.push(` HRV: ${summary.avg_hrv ?? "\u2014"} Lowest HR: ${summary.lowest_hr ?? "\u2014"} Efficiency: ${summary.efficiency ?? "\u2014"}%`);
2913
3064
  }
2914
- return lines.join(`
3065
+ if (summary.partial)
3066
+ lines.push("", PARTIAL_NOTE);
3067
+ return finish(lines).join(`
2915
3068
  `);
2916
3069
  }
3070
+ var PARTIAL_NOTE = " * activity totals are not final.";
3071
+ var PUBLISH_DELAY_NOTE = "Oura publishes a day's summary after that night's sleep syncs from the ring.";
2917
3072
  var SUMMARY_INDENT = 4;
2918
3073
  var SUMMARY_GAP = 2;
2919
3074
  var SUMMARY_MAX_COLUMNS = 4;
@@ -2956,10 +3111,10 @@ function formatWeekTable(days, format, emptyHint) {
2956
3111
  ].join(`
2957
3112
  `);
2958
3113
  }
2959
- const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
2960
- const sep = source_default.gray("\u2500".repeat(56));
2961
- const rows = days.map((d) => `${padRight(d.partial ? `${d.day}*` : 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)}`);
2962
- const note = days.some((d) => d.partial) ? [" * still accumulating; its activity totals are not final."] : [];
3114
+ const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} Stress`;
3115
+ const rows = days.map((d) => `${padRight(d.partial ? `${d.day}*` : 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)} ${d.stress ?? "\u2014"}`);
3116
+ const sep = rule(Math.max(visibleWidth(header), ...rows.map(visibleWidth)));
3117
+ const note = days.some((d) => d.partial) ? [PARTIAL_NOTE] : [];
2963
3118
  return [`
2964
3119
  Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`), ...note].join(`
2965
3120
  `);
@@ -2970,14 +3125,14 @@ function formatTrends(trends, days, format, emptyHint) {
2970
3125
  const lines = [
2971
3126
  "",
2972
3127
  source_default.bold(` Trends: last ${days} days`),
2973
- source_default.gray("\u2500".repeat(50))
3128
+ RULE
2974
3129
  ];
2975
3130
  if (emptyHint && trends.length === 0)
2976
3131
  lines.push(` No Oura data for the last ${days} days yet.`, ` ${emptyHint}`);
2977
3132
  for (const t of trends) {
2978
3133
  lines.push(` ${t.label.padEnd(15)} avg: ${String(t.avg).padStart(5)} min: ${String(t.min).padStart(5)} max: ${String(t.max).padStart(5)} (${t.count} days)`);
2979
3134
  }
2980
- return lines.join(`
3135
+ return finish(lines).join(`
2981
3136
  `);
2982
3137
  }
2983
3138
  function formatStats(stats, format, emptyHint) {
@@ -2986,15 +3141,18 @@ function formatStats(stats, format, emptyHint) {
2986
3141
  const lines = [
2987
3142
  "",
2988
3143
  source_default.bold(" Database Statistics"),
2989
- source_default.gray("\u2550".repeat(50))
3144
+ DOUBLE_RULE
2990
3145
  ];
2991
3146
  if (emptyHint && stats.tables.every((t) => t.rows === 0)) {
2992
- return [...lines, " No Oura data in the database yet.", ` ${emptyHint}`].join(`
3147
+ return finish([...lines, " No Oura data in the database yet.", ` ${emptyHint}`]).join(`
2993
3148
  `);
2994
3149
  }
2995
3150
  for (const t of stats.tables) {
2996
- lines.push(` ${t.table.padEnd(22)} ${String(t.rows).padStart(8)} rows`);
3151
+ lines.push(` ${`${t.collection} (${t.table})`.padEnd(38)} ${String(t.rows).padStart(8)} row${t.rows === 1 ? "" : "s"}`);
2997
3152
  }
3153
+ const noDailyData = stats.dateRange.first === null && stats.trends.length === 0 && stats.records.mostSteps === null && stats.records.bestSleep === null;
3154
+ if (emptyHint && noDailyData)
3155
+ lines.push("", " No daily summaries in the database yet.", ` ${emptyHint}`);
2998
3156
  if (stats.dateRange.first) {
2999
3157
  lines.push(`
3000
3158
  Date range: ${stats.dateRange.first} \u2192 ${stats.dateRange.last}`);
@@ -3009,7 +3167,7 @@ function formatStats(stats, format, emptyHint) {
3009
3167
  if (stats.records.bestSleep) {
3010
3168
  lines.push(` Best sleep: ${stats.records.bestSleep.score} on ${stats.records.bestSleep.day}`);
3011
3169
  }
3012
- return lines.join(`
3170
+ return finish(lines).join(`
3013
3171
  `);
3014
3172
  }
3015
3173
 
@@ -3047,6 +3205,7 @@ function resolvePruneScope(value) {
3047
3205
  }
3048
3206
  return [...new Set(wanted)];
3049
3207
  }
3208
+ var TODAY_HINT_AFTER_SYNC = `${PUBLISH_DELAY_NOTE} Run \`oura-cli sync\` again later.`;
3050
3209
  async function runSync(ctx, window = {}, options = {}) {
3051
3210
  const lines = [];
3052
3211
  const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
@@ -3054,7 +3213,7 @@ async function runSync(ctx, window = {}, options = {}) {
3054
3213
  const today = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
3055
3214
  return {
3056
3215
  json: { import: importResult, today },
3057
- text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table")].join(`
3216
+ text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table", TODAY_HINT_AFTER_SYNC)].join(`
3058
3217
  `)
3059
3218
  };
3060
3219
  }
@@ -3064,15 +3223,171 @@ var syncArgs = {
3064
3223
  prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
3065
3224
  };
3066
3225
  var syncDef = {
3067
- meta: { name: "sync", description: "Import latest data from Oura API and return today's summary" },
3226
+ meta: { name: "sync", description: "Download new Oura data into the local cache and report what each collection fetched" },
3068
3227
  args: syncArgs,
3069
3228
  needs: { db: true, client: true },
3070
3229
  run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
3071
3230
  };
3072
3231
  var syncCommand = dataCommand(syncDef);
3073
3232
 
3233
+ // src/db/rows.ts
3234
+ function getRows(db, c, range, tz) {
3235
+ const cols = c.columns.map((k) => k.name);
3236
+ if (range !== null && !cols.includes("day"))
3237
+ throw new Error(`getRows: ${c.name} has no day column to bound a range on.`);
3238
+ const order = [...new Set([...cols.includes("day") ? ["day"] : [], ...identityColumns(c)])].join(", ");
3239
+ const select = `SELECT ${cols.join(", ")} FROM ${c.table}`;
3240
+ if (range === null)
3241
+ return db.query(`${select} ORDER BY ${order}`).all();
3242
+ const between = db.query(`${select} WHERE day BETWEEN ? AND ? ORDER BY ${order}`);
3243
+ if (c.rangeParams !== "datetime")
3244
+ return between.all(range.start, range.end);
3245
+ const fromMs = Date.parse(localDateToUtcRange(range.start, tz)[0]);
3246
+ const toMs = Date.parse(localDateToUtcRange(range.end, tz)[1]);
3247
+ const instant = (r) => Date.parse(String(r.timestamp));
3248
+ return between.all(shiftDay(range.start, -1), shiftDay(range.end, 1)).filter((r) => {
3249
+ const t = instant(r);
3250
+ return t >= fromMs && t < toMs;
3251
+ }).sort((a, b) => instant(a) - instant(b));
3252
+ }
3253
+
3254
+ // src/render/format-rows.ts
3255
+ init_source();
3256
+ var MAX_CELL = 40;
3257
+ var MIN_CELL = 8;
3258
+ var GAP = 2;
3259
+ function printable(value) {
3260
+ if (value === null)
3261
+ return "\u2014";
3262
+ return String(value).replace(/\r\n|\r|\n/g, "\u23CE").replace(/[\u0000-\u001f\u007f]/g, " ");
3263
+ }
3264
+ function clip(text, width) {
3265
+ return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}\u2026` : text;
3266
+ }
3267
+ function widest(values, floor = 0) {
3268
+ let out = floor;
3269
+ for (const v of values)
3270
+ if (v > out)
3271
+ out = v;
3272
+ return out;
3273
+ }
3274
+ function fit(natural, floors, available) {
3275
+ const out = [...natural];
3276
+ while (out.reduce((sum, w) => sum + w, 0) > available) {
3277
+ let target = -1;
3278
+ for (let i = 0;i < out.length; i++) {
3279
+ if (out[i] > floors[i] && (target === -1 || out[i] > out[target]))
3280
+ target = i;
3281
+ }
3282
+ if (target === -1)
3283
+ return null;
3284
+ out[target]--;
3285
+ }
3286
+ return out;
3287
+ }
3288
+ function records(names, texts, max) {
3289
+ const nameW = widest(names.map(visibleWidth));
3290
+ const valueW = max === undefined ? Number.POSITIVE_INFINITY : Math.max(MIN_CELL, max - INDENT.length - nameW - GAP);
3291
+ const lines = [];
3292
+ texts.forEach((row, r) => {
3293
+ if (r > 0)
3294
+ lines.push("");
3295
+ names.forEach((name, i) => lines.push(`${INDENT}${padRight(name, nameW)}${" ".repeat(GAP)}${clip(row[i], valueW)}`.trimEnd()));
3296
+ });
3297
+ return lines;
3298
+ }
3299
+ function formatRows(c, rows, scope, format, emptyHint, max = screenWidth(), total = rows.length) {
3300
+ if (format === "json")
3301
+ return JSON.stringify(rows, null, 2);
3302
+ const all = Math.max(total, rows.length);
3303
+ const count = rows.length === all ? `${all} row${all === 1 ? "" : "s"}` : `${rows.length} of ${all} rows`;
3304
+ const title = source_default.bold(` ${c.name} (${c.table}): ${count}${scope}`);
3305
+ if (rows.length === 0) {
3306
+ return finish(["", title, RULE, ` No cached ${c.name} rows${scope}.`, ` ${emptyHint}`], max).join(`
3307
+ `);
3308
+ }
3309
+ const names = c.columns.map((k) => k.name);
3310
+ const numeric = new Set(c.columns.filter((k) => k.type !== "TEXT").map((k) => k.name));
3311
+ const texts = rows.map((r) => names.map((name) => clip(printable(r[name] ?? null), MAX_CELL)));
3312
+ const headers = names.map(visibleWidth);
3313
+ const natural = names.map((_, i) => widest(texts.map((row) => visibleWidth(row[i])), headers[i]));
3314
+ const gaps = GAP * (names.length - 1);
3315
+ const widths = max === undefined ? natural : fit(natural, headers.map((h) => Math.max(h, MIN_CELL)), max - INDENT.length - gaps);
3316
+ if (widths === null)
3317
+ return finish(["", title, RULE, ...records(names, texts, max)], max).join(`
3318
+ `);
3319
+ const line = (parts) => `${INDENT}${parts.map((p, i) => numeric.has(names[i]) ? padLeft(clip(p, widths[i]), widths[i]) : padRight(clip(p, widths[i]), widths[i])).join(" ".repeat(GAP))}`.trimEnd();
3320
+ return finish(["", title, RULE, line(names), RULE, ...texts.map(line)], max).join(`
3321
+ `);
3322
+ }
3323
+
3324
+ // src/commands/fetch.ts
3325
+ function resolveRange(opts) {
3326
+ const modes = [opts.day !== undefined, opts.from !== undefined || opts.to !== undefined, opts.days !== undefined].filter(Boolean).length;
3327
+ if (modes > 1)
3328
+ throw new CliError("BAD_ARGS", "Use only one of --day, --from/--to, or --days.");
3329
+ if (opts.day !== undefined) {
3330
+ const d = assertCalendarDate(opts.day, "--day");
3331
+ return { start: d, end: d };
3332
+ }
3333
+ if (opts.from !== undefined || opts.to !== undefined) {
3334
+ if (opts.from === undefined || opts.to === undefined)
3335
+ throw new CliError("BAD_ARGS", "--from and --to must be given together.");
3336
+ const start = assertCalendarDate(opts.from, "--from");
3337
+ const end = assertCalendarDate(opts.to, "--to");
3338
+ if (start > end)
3339
+ throw new CliError("BAD_ARGS", `--from (${start}) must not be after --to (${end}).`);
3340
+ return { start, end };
3341
+ }
3342
+ if (opts.days !== undefined) {
3343
+ const n = assertPositiveInt(opts.days, "--days");
3344
+ return { start: shiftDay(opts.today, -(n - 1)), end: opts.today };
3345
+ }
3346
+ return { start: opts.today, end: opts.today };
3347
+ }
3348
+ function assertRangeAllowed(c, opts) {
3349
+ if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
3350
+ throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
3351
+ }
3352
+ }
3353
+ var fetchCommand = dataCommand({
3354
+ meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
3355
+ args: {
3356
+ collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
3357
+ day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
3358
+ from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
3359
+ to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
3360
+ days: { type: "string", description: "Last N days ending today" }
3361
+ },
3362
+ jsonOnly: true,
3363
+ async run(ctx, args) {
3364
+ const c = byName(args.collection);
3365
+ if (!c)
3366
+ throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
3367
+ const opts = {
3368
+ day: args.day,
3369
+ from: args.from,
3370
+ to: args.to,
3371
+ days: args.days
3372
+ };
3373
+ assertRangeAllowed(c, opts);
3374
+ const { start, end } = resolveRange({ ...opts, today: ctx.today });
3375
+ const progress = ctx.progress ? pageProgress(ctx.progress, "fetching") : undefined;
3376
+ const client = new OuraClient({
3377
+ ...args.token ? { token: args.token } : {},
3378
+ ...progress ? { onPage: progress.onPage, onRetry: progress.onRetry } : {}
3379
+ });
3380
+ try {
3381
+ const data = await fetchCollection(client, c, start, end, ctx.tz);
3382
+ return { json: data, text: () => JSON.stringify(data, null, 2) };
3383
+ } finally {
3384
+ progress?.done();
3385
+ }
3386
+ }
3387
+ });
3388
+
3074
3389
  // src/commands/db.ts
3075
- var SYNC_HINT = "Run `oura-cli sync` to download your data. Oura publishes a day's summary after that night's sleep syncs from the ring.";
3390
+ var SYNC_HINT = `Run \`oura-cli sync\` to download your data. ${PUBLISH_DELAY_NOTE}`;
3076
3391
  var dbCommand = defineCommand({
3077
3392
  meta: { name: "db", description: "Query and manage the local SQLite database" },
3078
3393
  subCommands: {
@@ -3120,6 +3435,37 @@ var dbCommand = defineCommand({
3120
3435
  const stats = getStats(ctx.db, ctx.today);
3121
3436
  return { json: stats, text: () => formatStats(stats, "table", "Run `oura-cli sync`, then `oura-cli db stats` again.") };
3122
3437
  }
3438
+ }),
3439
+ rows: dataCommand({
3440
+ meta: { name: "rows", description: "Cached rows of one collection, as stored: the local twin of `fetch`" },
3441
+ args: {
3442
+ collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
3443
+ day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
3444
+ from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
3445
+ to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
3446
+ days: { type: "string", description: "Last N days ending today" },
3447
+ limit: { type: "string", description: "Print at most N rows, the earliest first (a day of heart rate is hundreds)" }
3448
+ },
3449
+ needs: { db: true },
3450
+ run(ctx, args) {
3451
+ const c = byName(String(args.collection));
3452
+ if (!c)
3453
+ throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
3454
+ const opts = {
3455
+ day: args.day,
3456
+ from: args.from,
3457
+ to: args.to,
3458
+ days: args.days
3459
+ };
3460
+ assertRangeAllowed(c, opts);
3461
+ const range = c.rangeParams === "none" ? null : resolveRange({ ...opts, today: ctx.today });
3462
+ const limit = args.limit === undefined ? undefined : assertPositiveInt(String(args.limit), "--limit");
3463
+ const all = getRows(ctx.db, c, range, ctx.tz);
3464
+ const rows = limit === undefined ? all : all.slice(0, limit);
3465
+ const scope = range === null ? "" : range.start === range.end ? ` for ${range.start}` : ` for ${range.start} \u2192 ${range.end}`;
3466
+ const hint = range === null ? `Run \`oura-cli sync\` to fill the cache, or \`oura-cli fetch ${c.name}\` to read the API.` : `Run \`oura-cli sync\` (\`sync --from <day>\` for older days), or \`oura-cli fetch ${c.name}\` to read the API.`;
3467
+ return { json: rows, text: () => formatRows(c, rows, scope, "table", hint, undefined, all.length) };
3468
+ }
3123
3469
  })
3124
3470
  }
3125
3471
  });
@@ -3286,7 +3632,7 @@ function partialDayNote(data, bucket) {
3286
3632
  return null;
3287
3633
  const which = bucket ? `the week of ${bucket.weekOf}` : partial.day === data.weekEnd ? "today" : partial.dayLabel;
3288
3634
  const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
3289
- return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
3635
+ return ` * ${which}: activity totals are not final; averages cover ${covers}.`;
3290
3636
  }
3291
3637
  function formatReport(data, format, period) {
3292
3638
  if (format === "json")
@@ -3314,25 +3660,21 @@ function formatReport(data, format, period) {
3314
3660
  }
3315
3661
  if (period === "week") {
3316
3662
  lines.push(source_default.bold(" Last 7 Days:"));
3317
- lines.push(source_default.gray(" " + "\u2500".repeat(52)));
3318
- lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
3319
- lines.push(source_default.gray(" " + "\u2500".repeat(52)));
3663
+ const table = [RULE, ` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`, RULE];
3320
3664
  for (const d of data.days) {
3321
- 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)}`);
3665
+ table.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)}`);
3322
3666
  }
3323
- lines.push("");
3667
+ lines.push(...finish(table), "");
3324
3668
  } else {
3325
3669
  lines.push(source_default.bold(" Last 30 Days:"));
3326
- lines.push(source_default.gray(" " + "\u2500".repeat(69)));
3327
- lines.push(` ${"Week of".padEnd(21)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
3328
- lines.push(source_default.gray(" " + "\u2500".repeat(69)));
3670
+ const table = [RULE, ` ${"Week of".padEnd(21)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`, RULE];
3329
3671
  for (const b of buckets) {
3330
3672
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
3331
3673
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
3332
3674
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
3333
- lines.push(` ${bucketLabel(b).padEnd(21)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
3675
+ table.push(` ${bucketLabel(b).padEnd(21)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
3334
3676
  }
3335
- lines.push("");
3677
+ lines.push(...finish(table), "");
3336
3678
  }
3337
3679
  lines.push(source_default.bold(" Averages (this period vs previous):"));
3338
3680
  for (const a of data.averages) {
@@ -3432,14 +3774,14 @@ function statusSymbol(status) {
3432
3774
  return source_default.red("\u2717");
3433
3775
  }
3434
3776
  function formatDoctorTable(result) {
3435
- const lines = ["", source_default.bold(" Doctor"), source_default.gray("\u2500".repeat(50))];
3777
+ const lines = ["", source_default.bold(" Doctor"), RULE];
3436
3778
  for (const c of result.checks) {
3437
3779
  lines.push(` ${statusSymbol(c.status)} ${c.id.padEnd(12)} ${c.detail}`);
3438
3780
  }
3439
3781
  lines.push("");
3440
3782
  const next = result.nextStep ?? (result.ok ? "nothing \u2014 everything looks healthy." : "see the failing checks above.");
3441
3783
  lines.push(` Next: ${next}`);
3442
- return lines.join(`
3784
+ return finish(lines).join(`
3443
3785
  `);
3444
3786
  }
3445
3787
 
@@ -3466,7 +3808,7 @@ async function runChecks(deps) {
3466
3808
  checks.push({ id: "token-valid", status: "fail", detail: err.message, fix: "oura-cli login" });
3467
3809
  } else {
3468
3810
  const msg = err instanceof Error ? err.message : String(err);
3469
- checks.push({ id: "token-valid", status: "warn", detail: `Could not reach the Oura API: ${msg}` });
3811
+ checks.push({ id: "token-valid", status: "warn", detail: `Could not reach the Oura API: ${msg}`, fix: "Check the network connection and run `oura-cli doctor` again in a few minutes." });
3470
3812
  }
3471
3813
  }
3472
3814
  }
@@ -3494,9 +3836,14 @@ async function runChecks(deps) {
3494
3836
  } else if (!last) {
3495
3837
  checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
3496
3838
  } else {
3497
- const ageDays = Math.round((new Date(`${deps.today}T00:00:00Z`).getTime() - new Date(`${last}T00:00:00Z`).getTime()) / 86400000);
3498
- if (ageDays > 2) {
3499
- checks.push({ id: "data", status: "warn", detail: `Most recent data is from ${last} (${ageDays} days ago).`, fix: "oura-cli sync" });
3839
+ const hours = hoursSinceDayEnded(last, deps.now, deps.tz);
3840
+ if (hours > STALE_AFTER_HOURS) {
3841
+ checks.push({
3842
+ id: "data",
3843
+ status: "warn",
3844
+ detail: `Most recent data is from ${last}; that day ended over ${Math.floor(hours)} hours ago (the limit is ${STALE_AFTER_HOURS}).`,
3845
+ fix: "oura-cli sync"
3846
+ });
3500
3847
  } else {
3501
3848
  checks.push({ id: "data", status: "ok", detail: `Data current through ${last}.` });
3502
3849
  }
@@ -3521,6 +3868,14 @@ function quickCheck(db) {
3521
3868
  }
3522
3869
  }
3523
3870
  var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
3871
+ var STALE_AFTER_HOURS = 36;
3872
+ function hoursSinceDayEnded(day, now, tz) {
3873
+ const ended = Date.parse(localDateToUtcRange(day, tz)[1]);
3874
+ const hours = (Date.parse(now) - ended) / 3600000;
3875
+ if (!Number.isFinite(hours))
3876
+ throw new Error(`hoursSinceDayEnded: cannot place ${JSON.stringify(now)} against day ${day}.`);
3877
+ return hours;
3878
+ }
3524
3879
  function latestDataDay(db) {
3525
3880
  let latest = null;
3526
3881
  for (const tbl of DATA_TABLES) {
@@ -3549,7 +3904,9 @@ async function runDoctor(ctx, args) {
3549
3904
  },
3550
3905
  createClient: (token) => new OuraClient({ token }),
3551
3906
  offline: args.offline === true,
3552
- today: ctx.today
3907
+ today: ctx.today,
3908
+ now: nowUtc(),
3909
+ tz: ctx.tz
3553
3910
  };
3554
3911
  const result = await runChecks(deps);
3555
3912
  return {
@@ -3603,63 +3960,6 @@ function manifestCommand(version, getCommands) {
3603
3960
  });
3604
3961
  }
3605
3962
 
3606
- // src/commands/fetch.ts
3607
- function resolveRange(opts) {
3608
- const modes = [opts.day !== undefined, opts.from !== undefined || opts.to !== undefined, opts.days !== undefined].filter(Boolean).length;
3609
- if (modes > 1)
3610
- throw new CliError("BAD_ARGS", "Use only one of --day, --from/--to, or --days.");
3611
- if (opts.day !== undefined) {
3612
- const d = assertCalendarDate(opts.day, "--day");
3613
- return { start: d, end: d };
3614
- }
3615
- if (opts.from !== undefined || opts.to !== undefined) {
3616
- if (opts.from === undefined || opts.to === undefined)
3617
- throw new CliError("BAD_ARGS", "--from and --to must be given together.");
3618
- const start = assertCalendarDate(opts.from, "--from");
3619
- const end = assertCalendarDate(opts.to, "--to");
3620
- if (start > end)
3621
- throw new CliError("BAD_ARGS", `--from (${start}) must not be after --to (${end}).`);
3622
- return { start, end };
3623
- }
3624
- if (opts.days !== undefined) {
3625
- const n = assertPositiveInt(opts.days, "--days");
3626
- return { start: shiftDay(opts.today, -(n - 1)), end: opts.today };
3627
- }
3628
- return { start: opts.today, end: opts.today };
3629
- }
3630
- function assertRangeAllowed(c, opts) {
3631
- if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
3632
- throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
3633
- }
3634
- }
3635
- var fetchCommand = dataCommand({
3636
- meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
3637
- args: {
3638
- collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
3639
- day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
3640
- from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
3641
- to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
3642
- days: { type: "string", description: "Last N days ending today" }
3643
- },
3644
- jsonOnly: true,
3645
- async run(ctx, args) {
3646
- const c = byName(args.collection);
3647
- if (!c)
3648
- throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
3649
- const opts = {
3650
- day: args.day,
3651
- from: args.from,
3652
- to: args.to,
3653
- days: args.days
3654
- };
3655
- assertRangeAllowed(c, opts);
3656
- const { start, end } = resolveRange({ ...opts, today: ctx.today });
3657
- const client = new OuraClient(args.token ? { token: args.token } : {});
3658
- const data = await fetchCollection(client, c, start, end, ctx.tz);
3659
- return { json: data, text: () => JSON.stringify(data, null, 2) };
3660
- }
3661
- });
3662
-
3663
3963
  // src/commands/registry.ts
3664
3964
  function buildRegistry(version) {
3665
3965
  const subCommands = Object.assign(Object.create(null), {
@@ -3707,11 +4007,28 @@ function nearestGlobalFlag(token) {
3707
4007
  return;
3708
4008
  return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
3709
4009
  }
3710
- function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
4010
+ function commandTokens(rawArgs) {
4011
+ const tokens = [];
4012
+ for (let i = 0;i < rawArgs.length; i++) {
4013
+ const tok = rawArgs[i];
4014
+ if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
4015
+ i++;
4016
+ continue;
4017
+ }
4018
+ if (tok.startsWith("-"))
4019
+ continue;
4020
+ tokens.push(tok);
4021
+ }
4022
+ return tokens;
4023
+ }
4024
+ function fromCittyError(err, removedCommandHints = {}, rawArgs = [], parents = {}) {
3711
4025
  const code = err?.code;
3712
4026
  if (typeof code !== "string")
3713
4027
  return err;
3714
4028
  const message = (err instanceof Error ? err.message : String(err)).replace(ANSI2, "");
4029
+ const first = commandTokens(rawArgs)[0];
4030
+ const parent = first !== undefined && Object.hasOwn(parents, first) ? first : undefined;
4031
+ const parentHelp = parent === undefined ? undefined : `\`oura-cli ${parent}\` takes one of: ${parents[parent].join(", ")}. Run \`oura-cli ${parent} --help\` for details.`;
3715
4032
  switch (code) {
3716
4033
  case "E_UNKNOWN_COMMAND": {
3717
4034
  const name = message.replace(/^Unknown command\s*/, "").trim();
@@ -3720,16 +4037,19 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3720
4037
  if (meant) {
3721
4038
  return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
3722
4039
  }
4040
+ if (parentHelp !== undefined && before !== undefined && GLOBAL_FLAGS_WITH_VALUE.has(before)) {
4041
+ return new CliError("BAD_ARGS", `"${parent}" needs a subcommand.`, parentHelp);
4042
+ }
3723
4043
  if (!COMMAND_NAME.test(name)) {
3724
4044
  return new CliError("BAD_ARGS", "Unknown command.", "A value was read as a command name. Check the flags before it, and run `oura-cli --help` for the list of commands.");
3725
4045
  }
3726
- const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
4046
+ const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : parentHelp ?? "Run `oura-cli --help` for the list of commands.";
3727
4047
  return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
3728
4048
  }
3729
4049
  case "EARG":
3730
4050
  return new CliError("BAD_ARGS", message.endsWith(".") ? message : `${message}.`, "Run the command with --help to see its arguments.");
3731
4051
  case "E_NO_COMMAND":
3732
- return new CliError("BAD_ARGS", "No command specified.", "Run `oura-cli --help` for the list of commands.");
4052
+ return parentHelp === undefined ? new CliError("BAD_ARGS", "No command specified.", "Run `oura-cli --help` for the list of commands.") : new CliError("BAD_ARGS", `"${parent}" needs a subcommand.`, parentHelp);
3733
4053
  default:
3734
4054
  return err;
3735
4055
  }
@@ -3738,6 +4058,10 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3738
4058
  // src/index.ts
3739
4059
  var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
3740
4060
  var subCommands = buildRegistry(VERSION);
4061
+ var PARENT_COMMANDS = Object.fromEntries(Object.entries(subCommands).flatMap(([name, def]) => {
4062
+ const subs = def.subCommands;
4063
+ return subs !== null && typeof subs === "object" ? [[name, Object.keys(subs)]] : [];
4064
+ }));
3741
4065
  var FETCH_HINT = "The per-collection commands were replaced in 0.5.0 by `oura-cli fetch <collection>`, e.g. `oura-cli fetch sleep --day 2026-09-01`. Run `oura-cli fetch --help`.";
3742
4066
  var REMOVED_COMMANDS = {
3743
4067
  reset: "`db reset` was removed in 0.5.0; delete the database file (`--db` / OURA_DB_PATH) and run `oura-cli sync` to rebuild it.",
@@ -3760,14 +4084,16 @@ var main = defineCommand({
3760
4084
  subCommands
3761
4085
  });
3762
4086
  var rawArgs = normalizeArgv(process.argv).slice(2);
3763
- var wantsHelp = rawArgs.some((a) => a === "--help" || a === "-h") || rawArgs.length === 0 && process.stdout.isTTY === true;
4087
+ var commands = commandTokens(rawArgs);
4088
+ var bareParent = commands.length === 1 && Object.hasOwn(PARENT_COMMANDS, commands[0]);
4089
+ var wantsHelp = rawArgs.some((a) => a === "--help" || a === "-h") || (commands.length === 0 || bareParent) && process.stdout.isTTY === true;
3764
4090
  if (isVersionRequest(rawArgs)) {
3765
4091
  console.log(VERSION);
3766
4092
  } else if (wantsHelp) {
3767
- runMain(main, { rawArgs });
4093
+ runMain(main, { rawArgs: bareParent ? [...rawArgs, "--help"] : rawArgs });
3768
4094
  } else {
3769
4095
  runCommand(main, { rawArgs }).catch((raw) => {
3770
- const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
4096
+ const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs, PARENT_COMMANDS);
3771
4097
  emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
3772
4098
  process.exit(exitCodeFor(err));
3773
4099
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/oura-cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",