@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 +13 -0
- package/README.md +5 -3
- package/dist/index.js +41 -19
- package/package.json +1 -1
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
|
|
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 (
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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