@drakulavich/oura-cli 0.7.0 → 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/dist/index.js CHANGED
@@ -1407,6 +1407,12 @@ CREATE TABLE IF NOT EXISTS ring_battery_level (
1407
1407
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ring_battery_level_unique ON ring_battery_level(timestamp);
1408
1408
  CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day);
1409
1409
  `
1410
+ },
1411
+ {
1412
+ version: 4,
1413
+ sql: `
1414
+ ALTER TABLE daily_activity ADD COLUMN class_5_min_slots INTEGER;
1415
+ `
1410
1416
  }
1411
1417
  ];
1412
1418
 
@@ -1528,11 +1534,46 @@ function resolveToken(explicit, tokenPath) {
1528
1534
 
1529
1535
  // src/api/client.ts
1530
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
+ }
1531
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
+ }
1532
1566
 
1533
1567
  class OuraClient {
1534
1568
  token;
1569
+ onPage;
1570
+ onRetry;
1571
+ sleep;
1572
+ waitedMs = 0;
1535
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)));
1536
1577
  const { token, source } = resolveToken(options.token, options.tokenPath);
1537
1578
  if (!token) {
1538
1579
  throw new CliError("TOKEN_MISSING", `No Oura access token at ${source}.`, "Run `oura-cli login` or set OURA_TOKEN.");
@@ -1553,7 +1594,8 @@ class OuraClient {
1553
1594
  const params = new URLSearchParams(query);
1554
1595
  if (nextToken)
1555
1596
  params.set("next_token", nextToken);
1556
- 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 });
1557
1599
  for (const row of page.data)
1558
1600
  rows.push(row);
1559
1601
  nextToken = page.next_token;
@@ -1565,17 +1607,38 @@ class OuraClient {
1565
1607
  } while (nextToken);
1566
1608
  return rows;
1567
1609
  }
1568
- async getPage(url) {
1569
- const response = await fetch(url, {
1570
- headers: { Authorization: `Bearer ${this.token}` }
1571
- });
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
+ }
1572
1631
  if (!response.ok) {
1573
1632
  const rawBody = await response.text();
1574
- const redacted = redactSecrets(rawBody);
1633
+ const redacted = redactSecrets(rawBody).split(this.token).join("[REDACTED]");
1575
1634
  const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
1576
1635
  if (response.status === 401 || response.status === 403) {
1577
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.");
1578
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
+ }
1579
1642
  throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
1580
1643
  }
1581
1644
  let json;
@@ -1585,6 +1648,15 @@ class OuraClient {
1585
1648
  throw new CliError("API_ERROR", "Empty response body from Oura API.");
1586
1649
  }
1587
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
+ }
1588
1660
  return { data: body.data ?? [], next_token: body.next_token ?? null };
1589
1661
  }
1590
1662
  }
@@ -1679,6 +1751,45 @@ function formatFromArgv(argv, isTty) {
1679
1751
  }
1680
1752
  }
1681
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
+
1682
1793
  // src/lib/time.ts
1683
1794
  function nowUtc() {
1684
1795
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -1788,7 +1899,8 @@ var processIo = {
1788
1899
  `);
1789
1900
  },
1790
1901
  exit: (code) => process.exit(code),
1791
- isTty: process.stdout.isTTY === true
1902
+ isTty: process.stdout.isTTY === true,
1903
+ progress: process.stderr.isTTY ? process.stderr : undefined
1792
1904
  };
1793
1905
  var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1794
1906
  function assertKnownArgs(declared, args) {
@@ -1809,7 +1921,7 @@ function assertKnownArgs(declared, args) {
1809
1921
  const unknown = Object.keys(args).filter((k) => !known.has(k));
1810
1922
  if (unknown.length > 0) {
1811
1923
  const flags = unknown.map((f) => f.length === 1 ? `-${f}` : `--${f}`).join(", ");
1812
- 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.";
1813
1925
  throw new CliError("BAD_ARGS", `Unknown flag${unknown.length > 1 ? "s" : ""}: ${flags}.`, hint);
1814
1926
  }
1815
1927
  const extra = (args._ ?? []).slice(positionals);
@@ -1828,16 +1940,25 @@ async function execute(def, args, io = processIo) {
1828
1940
  assertKnownArgs({ ...commonArgs, ...def.args ?? {} }, args);
1829
1941
  const outputFormat = def.jsonOnly ? "json" : format;
1830
1942
  const tz = assertTimezone(args.tz ?? resolveDefaultTimezone());
1831
- const ctx = { format: outputFormat, tz, today: today(tz) };
1943
+ const ctx = { format: outputFormat, tz, today: today(tz), ...io.progress ? { progress: io.progress } : {} };
1832
1944
  if (def.needs?.db) {
1833
1945
  db = openDatabase(args.db);
1834
1946
  ensureSchema(db);
1835
1947
  ctx.db = db;
1836
1948
  }
1949
+ const progress = def.needs?.client && io.progress ? pageProgress(io.progress, "syncing") : undefined;
1837
1950
  if (def.needs?.client) {
1838
- 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();
1839
1961
  }
1840
- const out = await def.run(ctx, args);
1841
1962
  io.stdout(outputFormat === "json" ? JSON.stringify(out.json, null, 2) : out.text());
1842
1963
  exitCode = out.exitCode ?? 0;
1843
1964
  } catch (raw) {
@@ -2013,7 +2134,8 @@ var activity = defineCollection({
2013
2134
  { name: "total_calories", type: "INTEGER", pick: (r) => r.total_calories },
2014
2135
  { name: "target_calories", type: "INTEGER", pick: (r) => r.target_calories },
2015
2136
  { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
2016
- { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
2137
+ { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp },
2138
+ { name: "class_5_min_slots", type: "INTEGER", pick: (r) => r.class_5_min?.length ?? null }
2017
2139
  ]
2018
2140
  });
2019
2141
 
@@ -2385,6 +2507,24 @@ function insertSql(c) {
2385
2507
  function rowValues(c, row) {
2386
2508
  return c.columns.map((col) => col.pick(row));
2387
2509
  }
2510
+ function identityColumns(c) {
2511
+ const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
2512
+ if (unique.length > 0)
2513
+ return unique;
2514
+ const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
2515
+ if (pk.length > 0)
2516
+ return pk;
2517
+ return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
2518
+ }
2519
+ function hasIdentity(c, row) {
2520
+ if (row == null || typeof row !== "object")
2521
+ return false;
2522
+ return identityColumns(c).every((name) => {
2523
+ const col = c.columns.find((k) => k.name === name);
2524
+ const v = col?.pick(row);
2525
+ return col?.type === "TEXT" ? typeof v === "string" && v !== "" : typeof v === "number" && Number.isFinite(v);
2526
+ });
2527
+ }
2388
2528
  var MS_PER_DAY = 86400000;
2389
2529
  function dateQueries(start, end, maxDays, offset) {
2390
2530
  const query = (s, e) => ({ start_date: shiftDay(s, offset[0]), end_date: shiftDay(e, offset[1]) });
@@ -2417,18 +2557,19 @@ function rangeQueries(c, start, end, tz) {
2417
2557
  async function fetchCollectionByPiece(client, c, start, end, tz) {
2418
2558
  const pieces = [];
2419
2559
  for (const query of rangeQueries(c, start, end, tz)) {
2420
- pieces.push(await client.fetch(c.endpoint, query));
2560
+ pieces.push({ query, rows: await client.fetch(c.endpoint, query) });
2421
2561
  }
2422
2562
  return pieces;
2423
2563
  }
2424
2564
  async function fetchCollection(client, c, start, end, tz) {
2425
- return (await fetchCollectionByPiece(client, c, start, end, tz)).flat();
2565
+ return (await fetchCollectionByPiece(client, c, start, end, tz)).flatMap((p) => p.rows);
2426
2566
  }
2427
2567
 
2428
2568
  // src/commands/describe.ts
2429
2569
  var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json", describe: "docs/schemas/describe.json" };
2430
2570
  var ENUM_ARGS = {
2431
2571
  fetch: { collection: names() },
2572
+ rows: { collection: names() },
2432
2573
  report: { period: ["week", "month"] }
2433
2574
  };
2434
2575
  function resolved(def) {
@@ -2525,21 +2666,25 @@ function describeCommand(version, getCommands) {
2525
2666
  }
2526
2667
 
2527
2668
  // src/db/reconcile.ts
2528
- function identityColumns(c) {
2529
- const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
2530
- if (unique.length > 0)
2531
- return unique;
2532
- const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
2533
- if (pk.length > 0)
2534
- return pk;
2535
- return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
2536
- }
2537
2669
  var MAX_REMOVED_SHARE = 0.5;
2538
2670
  var ALWAYS_SAFE_TO_REMOVE = 5;
2539
2671
  var KEY_SEPARATOR = "\x00";
2540
2672
  function emptyPlan() {
2541
2673
  return { added: 0, stale: [], refused: 0, bypassed: 0 };
2542
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
+ }
2543
2688
  function keyOf(values) {
2544
2689
  return values.map((v) => String(v)).join(KEY_SEPARATOR);
2545
2690
  }
@@ -2554,12 +2699,13 @@ function planWindow(db, c, pieces, options = {}) {
2554
2699
  if (scopePick === undefined || identityPicks.some((p) => p === undefined))
2555
2700
  return emptyPlan();
2556
2701
  const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
2557
- const wanted = new Set(pieces.flat().map(keyFor));
2702
+ const wanted = new Set(pieces.flatMap((p) => p.rows).map(keyFor));
2558
2703
  const plan = emptyPlan();
2559
2704
  const judged = pieces.map((piece) => {
2560
- if (piece.length === 0)
2705
+ if (piece.rows.length === 0)
2561
2706
  return null;
2562
- 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);
2563
2709
  if (scopeValues.length === 0)
2564
2710
  return null;
2565
2711
  const days = [...new Set(scopeValues)];
@@ -2575,7 +2721,7 @@ function planWindow(db, c, pieces, options = {}) {
2575
2721
  stale.push(values);
2576
2722
  }
2577
2723
  const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
2578
- 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));
2579
2725
  return { stale, looksTruncated, fresh };
2580
2726
  });
2581
2727
  const doubted = new Set;
@@ -2655,12 +2801,18 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2655
2801
  const fetched = {};
2656
2802
  const added = {};
2657
2803
  const removed = {};
2804
+ const dropped = {};
2658
2805
  const refused = {};
2659
2806
  const pruned = {};
2660
2807
  const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
2661
2808
  for (const { c, start } of plan) {
2662
- const pieces = await fetchCollectionByPiece(client, c, start, end, tz);
2663
- const rows = pieces.flat();
2809
+ const returned = await fetchCollectionByPiece(client, c, start, end, tz);
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;
2813
+ if (missing > 0)
2814
+ dropped[c.table] = missing;
2815
+ const droppedTail = missing > 0 ? `, ${missing} dropped (no ${identityColumns(c).join("/")})` : "";
2664
2816
  const stmt = db.query(insertSql(c));
2665
2817
  if (c.rangeParams === "none") {
2666
2818
  const pk = c.columns.find((col) => col.pk)?.name;
@@ -2668,24 +2820,38 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2668
2820
  throw new Error(`Snapshot collection ${c.name} must declare a primary-key column (enforced by the registry tests).`);
2669
2821
  const ids = () => new Set(db.query(`SELECT ${pk} AS id FROM ${c.table}`).all().map((r) => r.id));
2670
2822
  const known = ids();
2671
- db.transaction((rs) => {
2672
- db.exec(`DELETE FROM ${c.table}`);
2673
- for (const r of rs)
2674
- stmt.run(...rowValues(c, r));
2675
- })(rows);
2676
- fetched[c.table] = rows.length;
2677
- added[c.table] = [...ids()].filter((id) => !known.has(id)).length;
2678
- _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${rows.length === 0 ? ", table cleared" : ""}`);
2823
+ const unstorable = rows.length === 0 && missing > 0;
2824
+ const refuse = rows.length === 0 && known.size > 0 && (unstorable || !mayPrune(c.name));
2825
+ if (!refuse) {
2826
+ db.transaction((rs) => {
2827
+ db.exec(`DELETE FROM ${c.table}`);
2828
+ for (const r of rs)
2829
+ stmt.run(...rowValues(c, r));
2830
+ })(rows);
2831
+ }
2832
+ const now = ids();
2833
+ const gone = [...known].filter((id) => !now.has(id)).length;
2834
+ fetched[c.table] = rows.length + missing;
2835
+ added[c.table] = [...now].filter((id) => !known.has(id)).length;
2836
+ if (gone > 0)
2837
+ removed[c.table] = gone;
2838
+ if (refuse && !unstorable)
2839
+ refused[c.table] = { rows: known.size, collection: c.name };
2840
+ else if (rows.length === 0 && gone > 0)
2841
+ pruned[c.table] = { rows: gone, collection: c.name };
2842
+ const tail = gone > 0 ? `, ${gone} stale removed${pruned[c.table] ? " (past the truncation guard)" : ""}` : "";
2843
+ const kept = !refuse ? "" : unstorable ? `, ${known.size} rows kept: the response held no storable rows` : `, ${known.size} rows kept that the API did not return \u2014 an empty answer describes nothing; re-run with --prune=${c.name} to apply it`;
2844
+ _log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
2679
2845
  continue;
2680
2846
  }
2681
2847
  const { windowPlan, gone } = db.transaction((ps) => {
2682
2848
  const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
2683
2849
  for (const piece of ps)
2684
- for (const r of piece)
2850
+ for (const r of piece.rows)
2685
2851
  stmt.run(...rowValues(c, r));
2686
2852
  return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
2687
2853
  }).immediate(pieces);
2688
- fetched[c.table] = rows.length;
2854
+ fetched[c.table] = rows.length + missing;
2689
2855
  added[c.table] = windowPlan.added;
2690
2856
  if (gone > 0)
2691
2857
  removed[c.table] = gone;
@@ -2695,7 +2861,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2695
2861
  pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
2696
2862
  const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
2697
2863
  const kept = windowPlan.refused > 0 ? `, ${windowPlan.refused} rows kept that the API did not return \u2014 too many to drop on one response; re-run with --prune=${c.name} to apply them` : "";
2698
- _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${tail}${kept}`);
2864
+ _log(` + ${c.name} (${c.table}): ${fetched[c.table]} fetched, ${added[c.table]} new${droppedTail}${tail}${kept}`);
2699
2865
  }
2700
2866
  _log("Import complete.");
2701
2867
  return {
@@ -2704,6 +2870,7 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2704
2870
  fetched,
2705
2871
  added,
2706
2872
  removed,
2873
+ dropped,
2707
2874
  refused,
2708
2875
  pruned,
2709
2876
  isFirstSync,
@@ -2711,8 +2878,28 @@ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2711
2878
  };
2712
2879
  }
2713
2880
 
2881
+ // src/db/day-complete.ts
2882
+ var SLOTS_PER_DAY = 288;
2883
+ function dayCompleteness(db, today) {
2884
+ const rows = db.query("SELECT day, class_5_min_slots AS slots FROM daily_activity").all();
2885
+ const slotsByDay = new Map(rows.map((r) => [r.day, r.slots]));
2886
+ const daysDesc = rows.map((r) => r.day).sort().reverse();
2887
+ const newestDay = daysDesc[0] ?? null;
2888
+ const isComplete = (day) => {
2889
+ if (!slotsByDay.has(day))
2890
+ return false;
2891
+ if ((slotsByDay.get(day) ?? 0) >= SLOTS_PER_DAY)
2892
+ return true;
2893
+ return day < today && newestDay !== null && newestDay > day;
2894
+ };
2895
+ return {
2896
+ isComplete,
2897
+ completeThrough: (start, end) => daysDesc.find((d) => d >= start && d <= end && isComplete(d)) ?? null
2898
+ };
2899
+ }
2900
+
2714
2901
  // src/db/queries.ts
2715
- function getDaySummary(db, day) {
2902
+ function getDaySummary(db, day, complete) {
2716
2903
  const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(day);
2717
2904
  const rd = db.query("SELECT score, temperature_deviation FROM daily_readiness WHERE day=?").get(day);
2718
2905
  const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(day);
@@ -2721,6 +2908,7 @@ function getDaySummary(db, day) {
2721
2908
  const sm = db.query(`SELECT total_sleep_duration, deep_sleep_duration, rem_sleep_duration, average_hrv, lowest_heart_rate, efficiency FROM sleep_model WHERE day=? AND type='long_sleep'`).get(day);
2722
2909
  return {
2723
2910
  day,
2911
+ partial: ac != null && !complete.isComplete(day),
2724
2912
  sleep_score: sl?.score ?? null,
2725
2913
  readiness_score: rd?.score ?? null,
2726
2914
  activity_score: ac?.score ?? null,
@@ -2739,15 +2927,17 @@ function getDaySummary(db, day) {
2739
2927
  function getTrends(db, days, today) {
2740
2928
  const start = shiftDay(today, -(days - 1));
2741
2929
  const results = [];
2930
+ const activityEnd = dayCompleteness(db, today).completeThrough(start, today) ?? shiftDay(start, -1);
2742
2931
  const metrics = [
2743
- ["Sleep Score", "daily_sleep", "score"],
2744
- ["Readiness", "daily_readiness", "score"],
2745
- ["Activity", "daily_activity", "score"],
2746
- ["Steps", "daily_activity", "steps"],
2747
- ["Active Cal", "daily_activity", "active_calories"]
2932
+ ["Sleep Score", "daily_sleep", "score", false],
2933
+ ["Readiness", "daily_readiness", "score", false],
2934
+ ["Activity", "daily_activity", "score", true],
2935
+ ["Steps", "daily_activity", "steps", true],
2936
+ ["Active Cal", "daily_activity", "active_calories", true]
2748
2937
  ];
2749
- for (const [label, table, col] of metrics) {
2750
- const row = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as count FROM ${table} WHERE day BETWEEN ? AND ?`).get(start, today);
2938
+ for (const [label, table, col, accumulates] of metrics) {
2939
+ const end = accumulates ? activityEnd : today;
2940
+ const row = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as count FROM ${table} WHERE day BETWEEN ? AND ?`).get(start, end);
2751
2941
  if (row.count > 0 && row.avg !== null) {
2752
2942
  results.push({ label, avg: +row.avg.toFixed(0), min: row.min, max: row.max, count: row.count });
2753
2943
  }
@@ -2761,7 +2951,7 @@ function getTrends(db, days, today) {
2761
2951
  function getStats(db, today) {
2762
2952
  const tables = COLLECTIONS.map((c) => {
2763
2953
  const row = db.query(`SELECT COUNT(*) as cnt FROM ${c.table}`).get();
2764
- return { table: c.table, rows: row.cnt };
2954
+ return { collection: c.name, table: c.table, rows: row.cnt };
2765
2955
  });
2766
2956
  const range = db.query("SELECT MIN(day) as first, MAX(day) as last FROM daily_sleep").get();
2767
2957
  const trends = getTrends(db, 99999, today);
@@ -2797,7 +2987,28 @@ function padRight(text, width) {
2797
2987
 
2798
2988
  // src/lib/terminal.ts
2799
2989
  function terminalWidth() {
2800
- 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);
2801
3012
  }
2802
3013
 
2803
3014
  // src/render/format.ts
@@ -2822,19 +3033,19 @@ function formatDaySummary(summary, format, emptyHint) {
2822
3033
  if (format === "json")
2823
3034
  return JSON.stringify(summary, null, 2);
2824
3035
  if (emptyHint && isEmptyDay(summary)) {
2825
- return [
3036
+ return finish([
2826
3037
  "",
2827
3038
  source_default.bold(` ${summary.day}`),
2828
- source_default.gray("\u2500".repeat(50)),
3039
+ RULE,
2829
3040
  ` No Oura data for ${summary.day} yet.`,
2830
3041
  ` ${emptyHint}`
2831
- ].join(`
3042
+ ]).join(`
2832
3043
  `);
2833
3044
  }
2834
3045
  const lines = [
2835
3046
  "",
2836
- source_default.bold(` ${summary.day}`),
2837
- source_default.gray("\u2500".repeat(50)),
3047
+ source_default.bold(` ${summary.partial ? `${summary.day}*` : summary.day}`),
3048
+ RULE,
2838
3049
  ` Sleep: ${scoreColor(summary.sleep_score)} Readiness: ${scoreColor(summary.readiness_score)} Activity: ${scoreColor(summary.activity_score)}`,
2839
3050
  ` Steps: ${summary.steps ?? source_default.gray("\u2014")}`
2840
3051
  ];
@@ -2851,9 +3062,13 @@ function formatDaySummary(summary, format, emptyHint) {
2851
3062
  lines.push(` Sleep: ${fmtHours(summary.sleep_hours)} total | ${fmtHours(summary.deep_hours)} deep | ${fmtHours(summary.rem_hours)} REM`);
2852
3063
  lines.push(` HRV: ${summary.avg_hrv ?? "\u2014"} Lowest HR: ${summary.lowest_hr ?? "\u2014"} Efficiency: ${summary.efficiency ?? "\u2014"}%`);
2853
3064
  }
2854
- return lines.join(`
3065
+ if (summary.partial)
3066
+ lines.push("", PARTIAL_NOTE);
3067
+ return finish(lines).join(`
2855
3068
  `);
2856
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.";
2857
3072
  var SUMMARY_INDENT = 4;
2858
3073
  var SUMMARY_GAP = 2;
2859
3074
  var SUMMARY_MAX_COLUMNS = 4;
@@ -2896,38 +3111,48 @@ function formatWeekTable(days, format, emptyHint) {
2896
3111
  ].join(`
2897
3112
  `);
2898
3113
  }
2899
- const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
2900
- const sep = source_default.gray("\u2500".repeat(56));
2901
- 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)}`);
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] : [];
2902
3118
  return [`
2903
- Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
3119
+ Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`), ...note].join(`
2904
3120
  `);
2905
3121
  }
2906
- function formatTrends(trends, days, format) {
3122
+ function formatTrends(trends, days, format, emptyHint) {
2907
3123
  if (format === "json")
2908
3124
  return JSON.stringify(trends, null, 2);
2909
3125
  const lines = [
2910
3126
  "",
2911
3127
  source_default.bold(` Trends: last ${days} days`),
2912
- source_default.gray("\u2500".repeat(50))
3128
+ RULE
2913
3129
  ];
3130
+ if (emptyHint && trends.length === 0)
3131
+ lines.push(` No Oura data for the last ${days} days yet.`, ` ${emptyHint}`);
2914
3132
  for (const t of trends) {
2915
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)`);
2916
3134
  }
2917
- return lines.join(`
3135
+ return finish(lines).join(`
2918
3136
  `);
2919
3137
  }
2920
- function formatStats(stats, format) {
3138
+ function formatStats(stats, format, emptyHint) {
2921
3139
  if (format === "json")
2922
3140
  return JSON.stringify(stats, null, 2);
2923
3141
  const lines = [
2924
3142
  "",
2925
3143
  source_default.bold(" Database Statistics"),
2926
- source_default.gray("\u2550".repeat(50))
3144
+ DOUBLE_RULE
2927
3145
  ];
3146
+ if (emptyHint && stats.tables.every((t) => t.rows === 0)) {
3147
+ return finish([...lines, " No Oura data in the database yet.", ` ${emptyHint}`]).join(`
3148
+ `);
3149
+ }
2928
3150
  for (const t of stats.tables) {
2929
- 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"}`);
2930
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}`);
2931
3156
  if (stats.dateRange.first) {
2932
3157
  lines.push(`
2933
3158
  Date range: ${stats.dateRange.first} \u2192 ${stats.dateRange.last}`);
@@ -2942,7 +3167,7 @@ function formatStats(stats, format) {
2942
3167
  if (stats.records.bestSleep) {
2943
3168
  lines.push(` Best sleep: ${stats.records.bestSleep.score} on ${stats.records.bestSleep.day}`);
2944
3169
  }
2945
- return lines.join(`
3170
+ return finish(lines).join(`
2946
3171
  `);
2947
3172
  }
2948
3173
 
@@ -2980,14 +3205,15 @@ function resolvePruneScope(value) {
2980
3205
  }
2981
3206
  return [...new Set(wanted)];
2982
3207
  }
3208
+ var TODAY_HINT_AFTER_SYNC = `${PUBLISH_DELAY_NOTE} Run \`oura-cli sync\` again later.`;
2983
3209
  async function runSync(ctx, window = {}, options = {}) {
2984
3210
  const lines = [];
2985
3211
  const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
2986
3212
  const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window, options);
2987
- const today = getDaySummary(ctx.db, ctx.today);
3213
+ const today = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
2988
3214
  return {
2989
3215
  json: { import: importResult, today },
2990
- text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table")].join(`
3216
+ text: () => [...lines, formatImportSummary(importResult), formatDaySummary(today, "table", TODAY_HINT_AFTER_SYNC)].join(`
2991
3217
  `)
2992
3218
  };
2993
3219
  }
@@ -2997,15 +3223,171 @@ var syncArgs = {
2997
3223
  prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
2998
3224
  };
2999
3225
  var syncDef = {
3000
- 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" },
3001
3227
  args: syncArgs,
3002
3228
  needs: { db: true, client: true },
3003
3229
  run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
3004
3230
  };
3005
3231
  var syncCommand = dataCommand(syncDef);
3006
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
+
3007
3389
  // src/commands/db.ts
3008
- 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}`;
3009
3391
  var dbCommand = defineCommand({
3010
3392
  meta: { name: "db", description: "Query and manage the local SQLite database" },
3011
3393
  subCommands: {
@@ -3013,7 +3395,7 @@ var dbCommand = defineCommand({
3013
3395
  meta: { name: "today", description: "Today's summary from local database" },
3014
3396
  needs: { db: true },
3015
3397
  run(ctx) {
3016
- const summary = getDaySummary(ctx.db, ctx.today);
3398
+ const summary = getDaySummary(ctx.db, ctx.today, dayCompleteness(ctx.db, ctx.today));
3017
3399
  return { json: summary, text: () => formatDaySummary(summary, "table", SYNC_HINT) };
3018
3400
  }
3019
3401
  }),
@@ -3023,7 +3405,7 @@ var dbCommand = defineCommand({
3023
3405
  needs: { db: true },
3024
3406
  run(ctx, args) {
3025
3407
  const day = assertCalendarDate(String(args.day), "<day>");
3026
- const summary = getDaySummary(ctx.db, day);
3408
+ const summary = getDaySummary(ctx.db, day, dayCompleteness(ctx.db, ctx.today));
3027
3409
  return { json: summary, text: () => formatDaySummary(summary, "table") };
3028
3410
  }
3029
3411
  }),
@@ -3031,7 +3413,8 @@ var dbCommand = defineCommand({
3031
3413
  meta: { name: "week", description: "Last 7 days from local database" },
3032
3414
  needs: { db: true },
3033
3415
  run(ctx) {
3034
- const days = daysBack(ctx.today, 7).map((d) => getDaySummary(ctx.db, d));
3416
+ const complete = dayCompleteness(ctx.db, ctx.today);
3417
+ const days = daysBack(ctx.today, 7).map((d) => getDaySummary(ctx.db, d, complete));
3035
3418
  return { json: days, text: () => formatWeekTable(days, "table", "Run `oura-cli sync`, then `oura-cli db week` again.") };
3036
3419
  }
3037
3420
  }),
@@ -3042,7 +3425,7 @@ var dbCommand = defineCommand({
3042
3425
  run(ctx, args) {
3043
3426
  const n = args.days === undefined ? 30 : assertPositiveInt(String(args.days), "<days>");
3044
3427
  const trends = getTrends(ctx.db, n, ctx.today);
3045
- return { json: trends, text: () => formatTrends(trends, n, "table") };
3428
+ return { json: trends, text: () => formatTrends(trends, n, "table", "Run `oura-cli sync`, then `oura-cli db trends` again.") };
3046
3429
  }
3047
3430
  }),
3048
3431
  stats: dataCommand({
@@ -3050,7 +3433,38 @@ var dbCommand = defineCommand({
3050
3433
  needs: { db: true },
3051
3434
  run(ctx) {
3052
3435
  const stats = getStats(ctx.db, ctx.today);
3053
- return { json: stats, text: () => formatStats(stats, "table") };
3436
+ return { json: stats, text: () => formatStats(stats, "table", "Run `oura-cli sync`, then `oura-cli db stats` again.") };
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) };
3054
3468
  }
3055
3469
  })
3056
3470
  }
@@ -3073,9 +3487,8 @@ function getReport(db, days, today) {
3073
3487
  const prevWeekStart = shiftDay(today, -(days * 2 - 1));
3074
3488
  const windowDays = daysBack(today, days);
3075
3489
  const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
3076
- const newestActivityDay = db.query("SELECT MAX(day) AS d FROM daily_activity").get().d;
3077
- const isComplete = (d) => d < today && newestActivityDay !== null && newestActivityDay > d;
3078
- const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
3490
+ const complete = dayCompleteness(db, today);
3491
+ const completeThrough = complete.completeThrough(weekStart, today);
3079
3492
  const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
3080
3493
  const dailyRows = [];
3081
3494
  for (const d of windowDays) {
@@ -3089,7 +3502,7 @@ function getReport(db, days, today) {
3089
3502
  readiness: rd?.score ?? null,
3090
3503
  activity: ac?.score ?? null,
3091
3504
  steps: ac?.steps ?? null,
3092
- partial: ac != null && !isComplete(d)
3505
+ partial: ac != null && !complete.isComplete(d)
3093
3506
  });
3094
3507
  }
3095
3508
  const metrics = [
@@ -3190,8 +3603,8 @@ var RECOMMENDATIONS = {
3190
3603
  };
3191
3604
  function bucketDaysIntoWeeks(days) {
3192
3605
  const buckets = [];
3193
- for (let i = 0;i < days.length; i += 7) {
3194
- const chunk = days.slice(i, i + 7);
3606
+ for (let end = days.length;end > 0; end -= 7) {
3607
+ const chunk = days.slice(Math.max(0, end - 7), end);
3195
3608
  const weekOf = chunk[0].day;
3196
3609
  const sleepVals = chunk.map((d) => d.sleep).filter((v) => v !== null);
3197
3610
  const readinessVals = chunk.map((d) => d.readiness).filter((v) => v !== null);
@@ -3199,6 +3612,7 @@ function bucketDaysIntoWeeks(days) {
3199
3612
  const stepsVals = chunk.map((d) => d.steps).filter((v) => v !== null);
3200
3613
  buckets.push({
3201
3614
  weekOf,
3615
+ days: chunk.length,
3202
3616
  avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
3203
3617
  avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
3204
3618
  avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
@@ -3206,15 +3620,19 @@ function bucketDaysIntoWeeks(days) {
3206
3620
  partial: chunk.some((d) => d.partial)
3207
3621
  });
3208
3622
  }
3209
- return buckets;
3623
+ return buckets.reverse();
3624
+ }
3625
+ function bucketLabel(b) {
3626
+ const stub = b.days < 7 ? ` (${b.days} day${b.days === 1 ? "" : "s"})` : "";
3627
+ return `${b.weekOf}${stub}${b.partial ? "*" : ""}`;
3210
3628
  }
3211
- function partialDayNote(data) {
3629
+ function partialDayNote(data, bucket) {
3212
3630
  const partial = data.days.find((d) => d.partial);
3213
3631
  if (!partial)
3214
3632
  return null;
3215
- const which = partial.day === data.weekEnd ? "today" : partial.dayLabel;
3633
+ const which = bucket ? `the week of ${bucket.weekOf}` : partial.day === data.weekEnd ? "today" : partial.dayLabel;
3216
3634
  const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
3217
- return ` * ${which} is still accumulating; activity averages cover ${covers}.`;
3635
+ return ` * ${which}: activity totals are not final; averages cover ${covers}.`;
3218
3636
  }
3219
3637
  function formatReport(data, format, period) {
3220
3638
  if (format === "json")
@@ -3227,7 +3645,8 @@ function formatReport(data, format, period) {
3227
3645
  lines.push(source_default.bold(" Oura Monthly Report"));
3228
3646
  }
3229
3647
  lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
3230
- const note = partialDayNote(data);
3648
+ const buckets = period === "month" ? bucketDaysIntoWeeks(data.days) : [];
3649
+ const note = partialDayNote(data, buckets.find((b) => b.partial));
3231
3650
  if (note)
3232
3651
  lines.push(source_default.yellow(note));
3233
3652
  lines.push("");
@@ -3241,26 +3660,21 @@ function formatReport(data, format, period) {
3241
3660
  }
3242
3661
  if (period === "week") {
3243
3662
  lines.push(source_default.bold(" Last 7 Days:"));
3244
- lines.push(source_default.gray(" " + "\u2500".repeat(52)));
3245
- lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
3246
- 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];
3247
3664
  for (const d of data.days) {
3248
- 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)}`);
3249
3666
  }
3250
- lines.push("");
3667
+ lines.push(...finish(table), "");
3251
3668
  } else {
3252
- const buckets = bucketDaysIntoWeeks(data.days);
3253
3669
  lines.push(source_default.bold(" Last 30 Days:"));
3254
- lines.push(source_default.gray(" " + "\u2500".repeat(60)));
3255
- lines.push(` ${"Week of".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`);
3256
- lines.push(source_default.gray(" " + "\u2500".repeat(60)));
3670
+ const table = [RULE, ` ${"Week of".padEnd(21)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(10)}`, RULE];
3257
3671
  for (const b of buckets) {
3258
3672
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
3259
3673
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
3260
3674
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
3261
- lines.push(` ${(b.partial ? b.weekOf + "*" : b.weekOf).padEnd(12)} ${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)}`);
3262
3676
  }
3263
- lines.push("");
3677
+ lines.push(...finish(table), "");
3264
3678
  }
3265
3679
  lines.push(source_default.bold(" Averages (this period vs previous):"));
3266
3680
  for (const a of data.averages) {
@@ -3360,14 +3774,14 @@ function statusSymbol(status) {
3360
3774
  return source_default.red("\u2717");
3361
3775
  }
3362
3776
  function formatDoctorTable(result) {
3363
- const lines = ["", source_default.bold(" Doctor"), source_default.gray("\u2500".repeat(50))];
3777
+ const lines = ["", source_default.bold(" Doctor"), RULE];
3364
3778
  for (const c of result.checks) {
3365
3779
  lines.push(` ${statusSymbol(c.status)} ${c.id.padEnd(12)} ${c.detail}`);
3366
3780
  }
3367
3781
  lines.push("");
3368
3782
  const next = result.nextStep ?? (result.ok ? "nothing \u2014 everything looks healthy." : "see the failing checks above.");
3369
3783
  lines.push(` Next: ${next}`);
3370
- return lines.join(`
3784
+ return finish(lines).join(`
3371
3785
  `);
3372
3786
  }
3373
3787
 
@@ -3394,7 +3808,7 @@ async function runChecks(deps) {
3394
3808
  checks.push({ id: "token-valid", status: "fail", detail: err.message, fix: "oura-cli login" });
3395
3809
  } else {
3396
3810
  const msg = err instanceof Error ? err.message : String(err);
3397
- 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." });
3398
3812
  }
3399
3813
  }
3400
3814
  }
@@ -3422,9 +3836,14 @@ async function runChecks(deps) {
3422
3836
  } else if (!last) {
3423
3837
  checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
3424
3838
  } else {
3425
- const ageDays = Math.round((new Date(`${deps.today}T00:00:00Z`).getTime() - new Date(`${last}T00:00:00Z`).getTime()) / 86400000);
3426
- if (ageDays > 2) {
3427
- 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
+ });
3428
3847
  } else {
3429
3848
  checks.push({ id: "data", status: "ok", detail: `Data current through ${last}.` });
3430
3849
  }
@@ -3449,6 +3868,14 @@ function quickCheck(db) {
3449
3868
  }
3450
3869
  }
3451
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
+ }
3452
3879
  function latestDataDay(db) {
3453
3880
  let latest = null;
3454
3881
  for (const tbl of DATA_TABLES) {
@@ -3477,7 +3904,9 @@ async function runDoctor(ctx, args) {
3477
3904
  },
3478
3905
  createClient: (token) => new OuraClient({ token }),
3479
3906
  offline: args.offline === true,
3480
- today: ctx.today
3907
+ today: ctx.today,
3908
+ now: nowUtc(),
3909
+ tz: ctx.tz
3481
3910
  };
3482
3911
  const result = await runChecks(deps);
3483
3912
  return {
@@ -3531,62 +3960,21 @@ function manifestCommand(version, getCommands) {
3531
3960
  });
3532
3961
  }
3533
3962
 
3534
- // src/commands/fetch.ts
3535
- function resolveRange(opts) {
3536
- const modes = [opts.day !== undefined, opts.from !== undefined || opts.to !== undefined, opts.days !== undefined].filter(Boolean).length;
3537
- if (modes > 1)
3538
- throw new CliError("BAD_ARGS", "Use only one of --day, --from/--to, or --days.");
3539
- if (opts.day !== undefined) {
3540
- const d = assertCalendarDate(opts.day, "--day");
3541
- return { start: d, end: d };
3542
- }
3543
- if (opts.from !== undefined || opts.to !== undefined) {
3544
- if (opts.from === undefined || opts.to === undefined)
3545
- throw new CliError("BAD_ARGS", "--from and --to must be given together.");
3546
- const start = assertCalendarDate(opts.from, "--from");
3547
- const end = assertCalendarDate(opts.to, "--to");
3548
- if (start > end)
3549
- throw new CliError("BAD_ARGS", `--from (${start}) must not be after --to (${end}).`);
3550
- return { start, end };
3551
- }
3552
- if (opts.days !== undefined) {
3553
- const n = assertPositiveInt(opts.days, "--days");
3554
- return { start: shiftDay(opts.today, -(n - 1)), end: opts.today };
3555
- }
3556
- return { start: opts.today, end: opts.today };
3557
- }
3558
- function assertRangeAllowed(c, opts) {
3559
- if (c.rangeParams === "none" && [opts.day, opts.from, opts.to, opts.days].some((v) => v !== undefined)) {
3560
- throw new CliError("BAD_ARGS", `"${c.name}" is a snapshot, not a day range; it takes no --day, --from/--to or --days.`);
3561
- }
3963
+ // src/commands/registry.ts
3964
+ function buildRegistry(version) {
3965
+ const subCommands = Object.assign(Object.create(null), {
3966
+ login: loginCommand,
3967
+ describe: describeCommand(version, () => subCommands),
3968
+ healthcheck: healthcheckCommand(version),
3969
+ doctor: doctorCommand,
3970
+ manifest: manifestCommand(version, () => subCommands),
3971
+ fetch: fetchCommand,
3972
+ sync: syncCommand,
3973
+ db: dbCommand,
3974
+ report: reportCommand
3975
+ });
3976
+ return subCommands;
3562
3977
  }
3563
- var fetchCommand = dataCommand({
3564
- meta: { name: "fetch", description: "Fetch raw records for one Oura collection straight from the API (JSON)." },
3565
- args: {
3566
- collection: { type: "positional", required: true, description: `Collection: ${names().join(" | ")} (ring is a snapshot and takes no range flags)` },
3567
- day: { type: "string", description: "Single day (YYYY-MM-DD). Default: today." },
3568
- from: { type: "string", description: "Range start (YYYY-MM-DD); requires --to" },
3569
- to: { type: "string", description: "Range end (YYYY-MM-DD); requires --from" },
3570
- days: { type: "string", description: "Last N days ending today" }
3571
- },
3572
- jsonOnly: true,
3573
- async run(ctx, args) {
3574
- const c = byName(args.collection);
3575
- if (!c)
3576
- throw new CliError("BAD_ARGS", `Unknown collection "${args.collection}".`, `Valid collections: ${names().join(", ")}`);
3577
- const opts = {
3578
- day: args.day,
3579
- from: args.from,
3580
- to: args.to,
3581
- days: args.days
3582
- };
3583
- assertRangeAllowed(c, opts);
3584
- const { start, end } = resolveRange({ ...opts, today: ctx.today });
3585
- const client = new OuraClient(args.token ? { token: args.token } : {});
3586
- const data = await fetchCollection(client, c, start, end, ctx.tz);
3587
- return { json: data, text: () => JSON.stringify(data, null, 2) };
3588
- }
3589
- });
3590
3978
 
3591
3979
  // src/lib/citty-error.ts
3592
3980
  var ANSI2 = /\u001b\[[0-9;]*m/g;
@@ -3619,11 +4007,28 @@ function nearestGlobalFlag(token) {
3619
4007
  return;
3620
4008
  return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
3621
4009
  }
3622
- 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 = {}) {
3623
4025
  const code = err?.code;
3624
4026
  if (typeof code !== "string")
3625
4027
  return err;
3626
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.`;
3627
4032
  switch (code) {
3628
4033
  case "E_UNKNOWN_COMMAND": {
3629
4034
  const name = message.replace(/^Unknown command\s*/, "").trim();
@@ -3632,16 +4037,19 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3632
4037
  if (meant) {
3633
4038
  return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
3634
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
+ }
3635
4043
  if (!COMMAND_NAME.test(name)) {
3636
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.");
3637
4045
  }
3638
- 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.";
3639
4047
  return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
3640
4048
  }
3641
4049
  case "EARG":
3642
4050
  return new CliError("BAD_ARGS", message.endsWith(".") ? message : `${message}.`, "Run the command with --help to see its arguments.");
3643
4051
  case "E_NO_COMMAND":
3644
- 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);
3645
4053
  default:
3646
4054
  return err;
3647
4055
  }
@@ -3649,17 +4057,11 @@ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3649
4057
 
3650
4058
  // src/index.ts
3651
4059
  var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf-8")).version;
3652
- var subCommands = Object.assign(Object.create(null), {
3653
- login: loginCommand,
3654
- describe: describeCommand(VERSION, () => subCommands),
3655
- healthcheck: healthcheckCommand(VERSION),
3656
- doctor: doctorCommand,
3657
- manifest: manifestCommand(VERSION, () => subCommands),
3658
- fetch: fetchCommand,
3659
- sync: syncCommand,
3660
- db: dbCommand,
3661
- report: reportCommand
3662
- });
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
+ }));
3663
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`.";
3664
4066
  var REMOVED_COMMANDS = {
3665
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.",
@@ -3682,14 +4084,16 @@ var main = defineCommand({
3682
4084
  subCommands
3683
4085
  });
3684
4086
  var rawArgs = normalizeArgv(process.argv).slice(2);
3685
- 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;
3686
4090
  if (isVersionRequest(rawArgs)) {
3687
4091
  console.log(VERSION);
3688
4092
  } else if (wantsHelp) {
3689
- runMain(main, { rawArgs });
4093
+ runMain(main, { rawArgs: bareParent ? [...rawArgs, "--help"] : rawArgs });
3690
4094
  } else {
3691
4095
  runCommand(main, { rawArgs }).catch((raw) => {
3692
- const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
4096
+ const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs, PARENT_COMMANDS);
3693
4097
  emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
3694
4098
  process.exit(exitCodeFor(err));
3695
4099
  });