@drakulavich/oura-cli 0.5.1 → 0.5.2

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,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.2] - 2026-09-06
10
+
11
+ ### Fixed
12
+ - `db trends N` covers N calendar days ending today; it used to include one day more than the heading claimed. (#52)
13
+ - `report` no longer averages a day whose activity is still accumulating as if it were complete. A day counts as complete once it is over in the report timezone and the ring has uploaded past its end (the newest heart-rate sample in the cache is the proxy); activity and steps averages, the high-activity pattern and the steps recommendation stop at the last complete day, while sleep and readiness — final once they exist — still use the whole window. The partial day stays in the daily table, marked `*`, with one line explaining it. (#57)
14
+ - A 401/403 from the Oura API carries a hint (`oura-cli login` with a fresh token), like a missing token already did. (#54)
15
+
16
+ ### Changed
17
+ - `manifest`: `healthcheck.expects` lists the `error` field that `healthcheck` emits when `ok` is false. (#54)
18
+ - `report` JSON: `days[].partial`, `completeThrough`, `lastUpload` and `averages[].count` are added; nothing is removed or renamed. (#57)
19
+ - README's automation section no longer claims JSON Schemas for every output shape (they cover `fetch` and `doctor`), explains that `doctor` and `healthcheck` exit 0 with `ok: false` when the probe itself ran, and records two quirks kept for compatibility: `report --period month` returns `weekStart`/`weekEnd`, and `heartrate.day` is the date written in Oura's timestamp, UTC in practice. (#54)
20
+
9
21
  ## [0.5.1] - 2026-09-05
10
22
 
11
23
  Fixes from the 0.5.0 exploratory testing sessions: the seam between citty and the command runner, and the sync window.
@@ -329,6 +341,7 @@ Fixes from the 0.5.0 exploratory testing sessions: the seam between citty and th
329
341
  - Local SQLite cache at `~/.oura-cli/oura.db`.
330
342
  - Auth via `oura-cli login`, `OURA_TOKEN`, `OURA_TOKEN_PATH`, or `~/.oura-token`.
331
343
 
344
+ [0.5.2]: https://github.com/drakulavich/oura-cli/releases/tag/v0.5.2
332
345
  [0.5.1]: https://github.com/drakulavich/oura-cli/releases/tag/v0.5.1
333
346
  [0.5.0]: https://github.com/drakulavich/oura-cli/releases/tag/v0.5.0
334
347
  [0.4.4]: https://github.com/drakulavich/oura-cli/releases/tag/v0.4.4
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.
99
+ Reports cover daily scores, averages, deltas vs the previous window, sleep details, and a short recommendation block. A day whose activity is still accumulating (today, or the last day before the ring stopped syncing) is shown with a `*` and kept out of the activity averages and recommendations; the JSON says so via `days[].partial` and `completeThrough`.
100
100
 
101
101
  ### Trends and stats
102
102
 
@@ -170,10 +170,12 @@ Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [ci
170
170
  If you're driving the CLI from a script or LLM harness:
171
171
 
172
172
  - `oura-cli describe` — JSON manifest of every command, argument, and output schema. Agents discover capabilities without scraping `--help`.
173
- - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes.
173
+ - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false.
174
+ - Gate on `.ok`, not on the exit code: `doctor` exits 0 with `ok: false` for any warning-level check (no data yet, stale data, Oura API unreachable), and `healthcheck` exits 0 with `ok: false` for an unusable database (the probe itself ran). `doctor --offline` skips the token-validation call, and a skipped check still counts towards `ok`.
174
175
  - Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
175
176
  - Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
176
- - JSON Schemas under [`docs/schemas/`](docs/schemas/) describe every output shape, semver-stable. Per-collection schemas pin the identity fields (`id`, `day`, `timestamp`) and allow the rest of the Oura record through unchanged, so new upstream fields never break validation.
177
+ - JSON Schemas under [`docs/schemas/`](docs/schemas/) cover `fetch <collection>`, `doctor` and the `describe` manifest itself, semver-stable; `describe` names the schema next to each command. Per-collection schemas pin the identity fields (`id`, `day`, `timestamp`) and allow the rest of the Oura record through unchanged, so new upstream fields never break validation. The local-data commands (`sync`, `db *`, `report`) have no schema files yet; their shapes are versioned through the CHANGELOG.
178
+ - Two contract quirks, kept for compatibility: `report --period month` returns its window as `weekStart`/`weekEnd`, and `heartrate.day` in the cache is the date written in Oura's timestamp (UTC in practice) while every `--day`/`--tz` argument is local.
177
179
 
178
180
  Plays cleanly with [OpenClaw](https://github.com/openclaw/openclaw) — `oura-cli manifest` returns the tool-registry shape. A first-party `oura-mcp` companion is on the roadmap.
179
181
 
package/dist/index.js CHANGED
@@ -1438,7 +1438,7 @@ class OuraClient {
1438
1438
  const redacted = redactSecrets(rawBody);
1439
1439
  const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
1440
1440
  if (response.status === 401 || response.status === 403) {
1441
- throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`);
1441
+ throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`, "Run `oura-cli login` with a fresh Personal Access Token, or check OURA_TOKEN.");
1442
1442
  }
1443
1443
  throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
1444
1444
  }
@@ -2092,7 +2092,7 @@ async function fetchCollection(client, c, start, end, tz) {
2092
2092
  }
2093
2093
 
2094
2094
  // src/commands/describe.ts
2095
- var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json" };
2095
+ var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json", describe: "docs/schemas/describe.json" };
2096
2096
  var ENUM_ARGS = {
2097
2097
  fetch: { collection: names() },
2098
2098
  report: { period: ["week", "month"] }
@@ -2255,7 +2255,7 @@ function getDaySummary(db, day) {
2255
2255
  };
2256
2256
  }
2257
2257
  function getTrends(db, days, today) {
2258
- const start = shiftDay(today, -days);
2258
+ const start = shiftDay(today, -(days - 1));
2259
2259
  const results = [];
2260
2260
  const metrics = [
2261
2261
  ["Sleep Score", "daily_sleep", "score"],
@@ -2513,15 +2513,19 @@ function dayLabel(dateStr) {
2513
2513
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2514
2514
  return `${day} ${dd}/${mm}`;
2515
2515
  }
2516
- function getReport(db, days, today) {
2516
+ function getReport(db, days, today, tz = "UTC") {
2517
2517
  const period = days <= 7 ? "week" : "month";
2518
2518
  const weekEnd = today;
2519
2519
  const weekStart = shiftDay(today, -(days - 1));
2520
2520
  const prevWeekEnd = shiftDay(today, -days);
2521
2521
  const prevWeekStart = shiftDay(today, -(days * 2 - 1));
2522
+ const windowDays = daysBack(today, days);
2523
+ const lastUpload = db.query("SELECT MAX(timestamp) AS t FROM heartrate").get().t;
2524
+ const isComplete = (d) => d < today && (lastUpload === null || Date.parse(localDateToUtcRange(d, tz)[1]) <= Date.parse(lastUpload));
2525
+ const completeThrough = [...windowDays].reverse().find(isComplete) ?? null;
2526
+ const activityEnd = completeThrough ?? shiftDay(weekStart, -1);
2522
2527
  const dailyRows = [];
2523
- for (let i = days - 1;i >= 0; i--) {
2524
- const d = shiftDay(today, -i);
2528
+ for (const d of windowDays) {
2525
2529
  const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(d);
2526
2530
  const rd = db.query("SELECT score FROM daily_readiness WHERE day=?").get(d);
2527
2531
  const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(d);
@@ -2531,7 +2535,8 @@ function getReport(db, days, today) {
2531
2535
  sleep: sl?.score ?? null,
2532
2536
  readiness: rd?.score ?? null,
2533
2537
  activity: ac?.score ?? null,
2534
- steps: ac?.steps ?? null
2538
+ steps: ac?.steps ?? null,
2539
+ partial: ac != null && !isComplete(d)
2535
2540
  });
2536
2541
  }
2537
2542
  const metrics = [
@@ -2542,7 +2547,8 @@ function getReport(db, days, today) {
2542
2547
  ];
2543
2548
  const averages = [];
2544
2549
  for (const [label, table, col, isSteps] of metrics) {
2545
- const curr = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as cnt FROM ${table} WHERE day BETWEEN ? AND ?`).get(weekStart, weekEnd);
2550
+ const end = table === "daily_activity" ? activityEnd : weekEnd;
2551
+ const curr = db.query(`SELECT AVG(${col}) as avg, MIN(${col}) as min, MAX(${col}) as max, COUNT(${col}) as cnt FROM ${table} WHERE day BETWEEN ? AND ?`).get(weekStart, end);
2546
2552
  const prev = db.query(`SELECT AVG(${col}) as avg FROM ${table} WHERE day BETWEEN ? AND ?`).get(prevWeekStart, prevWeekEnd);
2547
2553
  if (curr.cnt > 0 && curr.avg !== null) {
2548
2554
  const diff = prev.avg !== null ? curr.avg - prev.avg : null;
@@ -2551,6 +2557,7 @@ function getReport(db, days, today) {
2551
2557
  avg: curr.avg,
2552
2558
  min: curr.min,
2553
2559
  max: curr.max,
2560
+ count: curr.cnt,
2554
2561
  prevAvg: prev.avg,
2555
2562
  diff,
2556
2563
  isSteps
@@ -2561,7 +2568,7 @@ function getReport(db, days, today) {
2561
2568
  const spo2 = sp.cnt > 0 && sp.avg !== null ? { avg: +sp.avg.toFixed(1), min: +sp.min.toFixed(1), max: +sp.max.toFixed(1) } : null;
2562
2569
  const lowSleep = db.query("SELECT day, score FROM daily_sleep WHERE day BETWEEN ? AND ? AND score < 70 ORDER BY score").all(weekStart, weekEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2563
2570
  const lowReadiness = db.query("SELECT day, score FROM daily_readiness WHERE day BETWEEN ? AND ? AND score < 70 ORDER BY score").all(weekStart, weekEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2564
- const highActivity = db.query("SELECT day, score, steps FROM daily_activity WHERE day BETWEEN ? AND ? AND score >= 90 ORDER BY score DESC").all(weekStart, weekEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2571
+ const highActivity = db.query("SELECT day, score, steps FROM daily_activity WHERE day BETWEEN ? AND ? AND score >= 90 ORDER BY score DESC").all(weekStart, activityEnd).map((r) => ({ ...r, dayLabel: dayLabel(r.day) }));
2565
2572
  const sd = db.query(`SELECT AVG(total_sleep_duration) as totalSleep, AVG(deep_sleep_duration) as deepSleep,
2566
2573
  AVG(rem_sleep_duration) as remSleep, AVG(light_sleep_duration) as lightSleep,
2567
2574
  AVG(efficiency) as efficiency, AVG(average_hrv) as hrv, AVG(lowest_heart_rate) as lowestHr
@@ -2570,7 +2577,7 @@ function getReport(db, days, today) {
2570
2577
  const recommendations = [];
2571
2578
  const avgSleep = db.query("SELECT AVG(score) as avg FROM daily_sleep WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2572
2579
  const avgReady = db.query("SELECT AVG(score) as avg FROM daily_readiness WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2573
- const avgSteps = db.query("SELECT AVG(steps) as avg FROM daily_activity WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2580
+ const avgSteps = db.query("SELECT AVG(steps) as avg FROM daily_activity WHERE day BETWEEN ? AND ?").get(weekStart, activityEnd);
2574
2581
  if (avgSleep.avg !== null && avgSleep.avg < 75) {
2575
2582
  recommendations.push("sleep_low");
2576
2583
  } else if (avgSleep.avg !== null && avgSleep.avg >= 85) {
@@ -2586,7 +2593,7 @@ function getReport(db, days, today) {
2586
2593
  } else if (avgSteps.avg !== null && avgSteps.avg >= 1e4) {
2587
2594
  recommendations.push("steps_great");
2588
2595
  }
2589
- return { period, weekStart, weekEnd, days: dailyRows, averages, spo2, patterns: { lowSleep, lowReadiness, highActivity }, sleepDetails, recommendations };
2596
+ return { period, weekStart, weekEnd, days: dailyRows, completeThrough, lastUpload, averages, spo2, patterns: { lowSleep, lowReadiness, highActivity }, sleepDetails, recommendations };
2590
2597
  }
2591
2598
 
2592
2599
  // src/render/format-report.ts
@@ -2642,12 +2649,24 @@ function bucketDaysIntoWeeks(days) {
2642
2649
  avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
2643
2650
  avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
2644
2651
  avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
2645
- totalSteps: stepsVals.length > 0 ? stepsVals.reduce((a, b) => a + b, 0) : null
2652
+ totalSteps: stepsVals.length > 0 ? stepsVals.reduce((a, b) => a + b, 0) : null,
2653
+ partial: chunk.some((d) => d.partial)
2646
2654
  });
2647
2655
  }
2648
2656
  return buckets;
2649
2657
  }
2650
- function formatReport(data, format, period) {
2658
+ function partialDayNote(data, tz) {
2659
+ const partial = data.days.filter((d) => d.partial);
2660
+ if (partial.length === 0)
2661
+ return null;
2662
+ const newest = partial[partial.length - 1];
2663
+ const newestLabel = newest.day === data.weekEnd ? "today" : newest.dayLabel;
2664
+ const which = partial.length === 1 ? `${newestLabel} is` : `${newestLabel} and ${partial.length - 1} earlier day${partial.length > 2 ? "s" : ""} are`;
2665
+ const synced = data.lastUpload ? `ring last synced ${formatLocal(data.lastUpload, tz)}` : "ring sync time unknown";
2666
+ const covers = data.completeThrough ? `through ${data.completeThrough}` : "no complete day yet";
2667
+ return ` * ${which} still accumulating (${synced}); activity averages cover ${covers}.`;
2668
+ }
2669
+ function formatReport(data, format, period, tz = "UTC") {
2651
2670
  if (format === "json")
2652
2671
  return JSON.stringify(data, null, 2);
2653
2672
  const lines = [];
@@ -2658,6 +2677,9 @@ function formatReport(data, format, period) {
2658
2677
  lines.push(source_default.bold(" Oura Monthly Report"));
2659
2678
  }
2660
2679
  lines.push(source_default.gray(` ${data.weekStart} \u2014 ${data.weekEnd}`));
2680
+ const note = partialDayNote(data, tz);
2681
+ if (note)
2682
+ lines.push(source_default.yellow(note));
2661
2683
  lines.push("");
2662
2684
  const hasReportData = data.days.some((day) => day.sleep !== null || day.readiness !== null || day.activity !== null || day.steps !== null) || data.averages.length > 0 || data.spo2 !== null || data.sleepDetails !== null;
2663
2685
  if (!hasReportData) {
@@ -2673,7 +2695,7 @@ function formatReport(data, format, period) {
2673
2695
  lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
2674
2696
  lines.push(source_default.gray(" " + "\u2500".repeat(52)));
2675
2697
  for (const d of data.days) {
2676
- lines.push(` ${d.dayLabel.padEnd(10)} ${scoreCell(d.sleep, 6)} ${scoreCell(d.readiness, 6)} ${scoreCell(d.activity, 7)} ${stepsCell(d.steps, 8)}`);
2698
+ lines.push(` ${(d.partial ? d.dayLabel + "*" : d.dayLabel).padEnd(10)} ${scoreCell(d.sleep, 6)} ${scoreCell(d.readiness, 6)} ${scoreCell(d.activity, 7)} ${stepsCell(d.steps, 8)}`);
2677
2699
  }
2678
2700
  lines.push("");
2679
2701
  } else {
@@ -2686,7 +2708,7 @@ function formatReport(data, format, period) {
2686
2708
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
2687
2709
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
2688
2710
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
2689
- lines.push(` ${b.weekOf.padEnd(12)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
2711
+ lines.push(` ${(b.partial ? b.weekOf + "*" : b.weekOf).padEnd(12)} ${scoreCell(avgSleepInt, 6)} ${scoreCell(avgReadyInt, 6)} ${scoreCell(avgActiveInt, 7)} ${stepsCell(b.totalSteps, 10)}`);
2690
2712
  }
2691
2713
  lines.push("");
2692
2714
  }
@@ -2747,15 +2769,15 @@ var reportCommand = dataCommand({
2747
2769
  if (period !== "week" && period !== "month") {
2748
2770
  throw new CliError("BAD_ARGS", `--period must be "week" or "month", got "${period}".`);
2749
2771
  }
2750
- const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today);
2751
- return { json: data, text: () => formatReport(data, "table", period) };
2772
+ const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today, ctx.tz);
2773
+ return { json: data, text: () => formatReport(data, "table", period, ctx.tz) };
2752
2774
  }
2753
2775
  });
2754
2776
 
2755
2777
  // src/commands/healthcheck.ts
2756
2778
  function healthcheckCommand(version) {
2757
2779
  return defineCommand({
2758
- meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs})." },
2780
+ meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs}, plus error when ok is false)." },
2759
2781
  args: { ...commonArgs },
2760
2782
  run({ args }) {
2761
2783
  assertKnownArgs(commonArgs, args);
@@ -2919,7 +2941,7 @@ function buildOpenclawManifest(version, commands) {
2919
2941
  examples: EXAMPLES[c.name] ?? [`oura-cli ${c.name}`]
2920
2942
  })),
2921
2943
  envVars: ["OURA_TOKEN", "OURA_TOKEN_PATH", "OURA_DB_PATH", "OURA_TZ"],
2922
- healthcheck: { command: "healthcheck", expects: { ok: "boolean", version: "string", latencyMs: "number" } }
2944
+ healthcheck: { command: "healthcheck", expects: { ok: "boolean", version: "string", latencyMs: "number", error: "string, present only when ok is false" } }
2923
2945
  };
2924
2946
  }
2925
2947
  function manifestCommand(version, getCommands) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/oura-cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",