@drakulavich/oura-cli 0.5.0 → 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/dist/index.js CHANGED
@@ -1105,9 +1105,9 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
1105
1105
 
1106
1106
  // src/commands/login.ts
1107
1107
  init_source();
1108
- import { writeFileSync, chmodSync, mkdirSync } from "fs";
1109
- import { resolve, dirname } from "path";
1110
- import { homedir } from "os";
1108
+ import { writeFileSync, chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "fs";
1109
+ import { resolve as resolve3, dirname as dirname2 } from "path";
1110
+ import { homedir as homedir3 } from "os";
1111
1111
 
1112
1112
  // src/lib/errors.ts
1113
1113
  init_source();
@@ -1144,8 +1144,8 @@ function redactSecrets(s) {
1144
1144
  }
1145
1145
  function formatError(err, format) {
1146
1146
  const code = err instanceof CliError ? err.code : "UNKNOWN";
1147
- const message = err instanceof Error ? err.message : String(err);
1148
- const hint = err instanceof CliError ? err.hint : undefined;
1147
+ const message = redactSecrets(err instanceof Error ? err.message : String(err));
1148
+ const hint = err instanceof CliError && err.hint ? redactSecrets(err.hint) : undefined;
1149
1149
  if (format === "json") {
1150
1150
  return {
1151
1151
  kind: "json",
@@ -1171,110 +1171,407 @@ var commonArgs = {
1171
1171
  "no-color": { type: "boolean", default: false, description: "Disable ANSI colors (also honors NO_COLOR env)" }
1172
1172
  };
1173
1173
 
1174
- // src/commands/login.ts
1175
- async function readHiddenToken(input, output) {
1176
- if (!input.isTTY || !input.setRawMode) {
1177
- throw new CliError("BAD_ARGS", "Interactive login requires a terminal.", "Use `oura-cli login --token` for non-interactive use.");
1174
+ // src/commands/run-command.ts
1175
+ init_source();
1176
+
1177
+ // src/db/open.ts
1178
+ import { Database, SQLiteError } from "bun:sqlite";
1179
+ import { resolve, dirname } from "path";
1180
+ import { homedir } from "os";
1181
+ import { chmodSync, existsSync, mkdirSync } from "fs";
1182
+
1183
+ // src/db/migrations.ts
1184
+ var MIGRATIONS = [
1185
+ {
1186
+ version: 1,
1187
+ sql: `
1188
+ CREATE TABLE IF NOT EXISTS daily_sleep (
1189
+ id TEXT PRIMARY KEY,
1190
+ day TEXT UNIQUE,
1191
+ score INTEGER,
1192
+ contributors TEXT,
1193
+ timestamp TEXT
1194
+ );
1195
+ CREATE TABLE IF NOT EXISTS daily_readiness (
1196
+ id TEXT PRIMARY KEY,
1197
+ day TEXT UNIQUE,
1198
+ score INTEGER,
1199
+ contributors TEXT,
1200
+ temperature_deviation REAL,
1201
+ temperature_trend_deviation REAL,
1202
+ timestamp TEXT
1203
+ );
1204
+ CREATE TABLE IF NOT EXISTS daily_activity (
1205
+ id TEXT PRIMARY KEY,
1206
+ day TEXT UNIQUE,
1207
+ score INTEGER,
1208
+ active_calories INTEGER,
1209
+ steps INTEGER,
1210
+ equivalent_walking_distance REAL,
1211
+ high_activity_time INTEGER,
1212
+ medium_activity_time INTEGER,
1213
+ low_activity_time INTEGER,
1214
+ sedentary_time INTEGER,
1215
+ total_calories INTEGER,
1216
+ target_calories INTEGER,
1217
+ contributors TEXT,
1218
+ timestamp TEXT
1219
+ );
1220
+ CREATE TABLE IF NOT EXISTS daily_spo2 (
1221
+ id TEXT PRIMARY KEY,
1222
+ day TEXT UNIQUE,
1223
+ spo2_average REAL,
1224
+ breathing_disturbance_index REAL
1225
+ );
1226
+ CREATE TABLE IF NOT EXISTS daily_stress (
1227
+ id TEXT PRIMARY KEY,
1228
+ day TEXT UNIQUE,
1229
+ day_summary TEXT,
1230
+ recovery_high INTEGER,
1231
+ stress_high INTEGER
1232
+ );
1233
+ CREATE TABLE IF NOT EXISTS heartrate (
1234
+ timestamp TEXT,
1235
+ bpm INTEGER,
1236
+ source TEXT,
1237
+ day TEXT
1238
+ );
1239
+ CREATE INDEX IF NOT EXISTS idx_heartrate_ts ON heartrate(timestamp);
1240
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_heartrate_unique ON heartrate(timestamp, source);
1241
+ CREATE INDEX IF NOT EXISTS idx_heartrate_day ON heartrate(day);
1242
+ CREATE TABLE IF NOT EXISTS vo2max (
1243
+ id TEXT PRIMARY KEY,
1244
+ day TEXT UNIQUE,
1245
+ vo2_max REAL,
1246
+ timestamp TEXT
1247
+ );
1248
+ CREATE TABLE IF NOT EXISTS cardiovascular_age (
1249
+ id TEXT PRIMARY KEY,
1250
+ day TEXT UNIQUE,
1251
+ vascular_age INTEGER
1252
+ );
1253
+ CREATE TABLE IF NOT EXISTS workouts (
1254
+ id TEXT PRIMARY KEY,
1255
+ day TEXT,
1256
+ activity TEXT,
1257
+ calories REAL,
1258
+ distance REAL,
1259
+ start_datetime TEXT,
1260
+ end_datetime TEXT,
1261
+ intensity TEXT,
1262
+ label TEXT,
1263
+ source TEXT
1264
+ );
1265
+ CREATE TABLE IF NOT EXISTS sleep_model (
1266
+ id TEXT PRIMARY KEY,
1267
+ day TEXT,
1268
+ average_breath REAL,
1269
+ average_heart_rate REAL,
1270
+ average_hrv REAL,
1271
+ awake_time INTEGER,
1272
+ bedtime_end TEXT,
1273
+ bedtime_start TEXT,
1274
+ deep_sleep_duration INTEGER,
1275
+ efficiency INTEGER,
1276
+ latency INTEGER,
1277
+ light_sleep_duration INTEGER,
1278
+ lowest_heart_rate INTEGER,
1279
+ period INTEGER,
1280
+ rem_sleep_duration INTEGER,
1281
+ restless_periods INTEGER,
1282
+ time_in_bed INTEGER,
1283
+ total_sleep_duration INTEGER,
1284
+ type TEXT
1285
+ );
1286
+ `
1287
+ },
1288
+ {
1289
+ version: 2,
1290
+ sql: `
1291
+ CREATE VIEW IF NOT EXISTS v_weekly_sleep AS
1292
+ SELECT strftime('%Y-W%W', day) as week, ROUND(AVG(score),1) as avg_score, COUNT(*) as days
1293
+ FROM daily_sleep WHERE score IS NOT NULL GROUP BY week ORDER BY week DESC;
1294
+
1295
+ CREATE VIEW IF NOT EXISTS v_weekly_readiness AS
1296
+ SELECT strftime('%Y-W%W', day) as week, ROUND(AVG(score),1) as avg_score,
1297
+ ROUND(AVG(temperature_deviation),2) as avg_temp_dev, COUNT(*) as days
1298
+ FROM daily_readiness WHERE score IS NOT NULL GROUP BY week ORDER BY week DESC;
1299
+
1300
+ CREATE VIEW IF NOT EXISTS v_weekly_activity AS
1301
+ SELECT strftime('%Y-W%W', day) as week, ROUND(AVG(score),1) as avg_score,
1302
+ SUM(steps) as total_steps, SUM(active_calories) as total_active_cal, COUNT(*) as days
1303
+ FROM daily_activity WHERE score IS NOT NULL GROUP BY week ORDER BY week DESC;
1304
+
1305
+ CREATE VIEW IF NOT EXISTS v_sleep_detail AS
1306
+ SELECT day, ROUND(total_sleep_duration/3600.0,1) as sleep_hours,
1307
+ ROUND(deep_sleep_duration/3600.0,1) as deep_hours,
1308
+ ROUND(rem_sleep_duration/3600.0,1) as rem_hours,
1309
+ average_hrv, average_heart_rate as avg_hr, lowest_heart_rate as lowest_hr, efficiency
1310
+ FROM sleep_model ORDER BY day DESC;
1311
+ `
1178
1312
  }
1179
- output.write("Paste your token (input hidden): ");
1180
- input.setRawMode(true);
1181
- input.resume();
1182
- return new Promise((resolve, reject) => {
1183
- let token = "";
1184
- const finish = () => {
1185
- input.off("data", onData);
1186
- input.setRawMode?.(false);
1187
- input.pause();
1188
- output.write(`
1189
- `);
1190
- };
1191
- const onData = (chunk) => {
1192
- for (const char of chunk.toString("utf8")) {
1193
- if (char === "\r" || char === `
1194
- `) {
1195
- finish();
1196
- resolve(token);
1197
- return;
1198
- }
1199
- if (char === "\x03" || char === "\x04") {
1200
- finish();
1201
- reject(new CliError("BAD_ARGS", "Login cancelled."));
1202
- return;
1203
- }
1204
- if (char === "\b" || char === "\x7F") {
1205
- token = token.slice(0, -1);
1206
- } else if (char >= " ") {
1207
- token += char;
1208
- }
1209
- }
1210
- };
1211
- input.on("data", onData);
1212
- });
1313
+ ];
1314
+
1315
+ // src/db/open.ts
1316
+ var DB_HINT = "Check the path in --db / OURA_DB_PATH and that the file is a SQLite database oura-cli created.";
1317
+ var BUSY_HINT = "Another oura-cli process is using this database; wait for it to finish and retry.";
1318
+ var BUSY_TIMEOUT_MS = 5000;
1319
+ function dbError(what, err) {
1320
+ const detail = err instanceof Error ? err.message : String(err);
1321
+ const hint = /database is locked|SQLITE_BUSY/i.test(detail) ? BUSY_HINT : DB_HINT;
1322
+ return new CliError("DB_ERROR", `${what}: ${detail}`, hint);
1213
1323
  }
1214
- function writeToken(path, token) {
1215
- const trimmed = token.trim();
1216
- if (trimmed.length === 0) {
1217
- throw new CliError("BAD_ARGS", "Token cannot be empty.");
1218
- }
1219
- mkdirSync(dirname(path), { recursive: true });
1220
- writeFileSync(path, trimmed, { encoding: "utf-8" });
1221
- if (process.platform !== "win32") {
1222
- chmodSync(path, 384);
1324
+ function asDbError(err) {
1325
+ return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
1326
+ }
1327
+ function getDbPath(explicit) {
1328
+ if (explicit)
1329
+ return explicit;
1330
+ if (process.env.OURA_DB_PATH)
1331
+ return process.env.OURA_DB_PATH;
1332
+ return resolve(homedir(), ".oura-cli", "oura.db");
1333
+ }
1334
+ function openDatabase(explicit) {
1335
+ const dbPath = getDbPath(explicit);
1336
+ try {
1337
+ const onDisk = dbPath !== ":memory:";
1338
+ const isNew = onDisk && !existsSync(dbPath);
1339
+ if (onDisk)
1340
+ mkdirSync(dirname(dbPath), { recursive: true, mode: 448 });
1341
+ const db = new Database(dbPath);
1342
+ if (isNew)
1343
+ chmodSync(dbPath, 384);
1344
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
1345
+ const mode = db.query("PRAGMA journal_mode").get();
1346
+ if (mode.journal_mode !== "wal")
1347
+ db.exec("PRAGMA journal_mode = WAL");
1348
+ db.exec("PRAGMA foreign_keys = ON");
1349
+ return db;
1350
+ } catch (err) {
1351
+ throw dbError(`Cannot open database ${dbPath}`, err);
1223
1352
  }
1224
1353
  }
1225
- var loginCommand = defineCommand({
1226
- meta: { name: "login", description: "Save an Oura Personal Access Token for future commands." },
1227
- args: {
1228
- token: { type: "string", description: "Pass token non-interactively (e.g. for scripts)" },
1229
- path: { type: "string", description: "Where to save the token (default: $OURA_TOKEN_PATH or ~/.oura-token)" },
1230
- "no-color": commonArgs["no-color"]
1231
- },
1232
- async run({ args }) {
1233
- try {
1234
- if (args["no-color"] || process.env.NO_COLOR) {
1235
- await Promise.resolve().then(() => init_source());
1236
- source_default.level = 0;
1237
- }
1238
- const target = args.path ?? process.env.OURA_TOKEN_PATH ?? resolve(homedir(), ".oura-token");
1239
- let token = args.token;
1240
- if (!token) {
1241
- console.log("Get a Personal Access Token at https://cloud.ouraring.com/personal-access-tokens");
1242
- token = await readHiddenToken(process.stdin, process.stdout);
1354
+ function schemaVersion(db) {
1355
+ db.exec("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)");
1356
+ const row = db.query("SELECT MAX(version) AS v FROM _schema_version").get();
1357
+ return row?.v ?? 0;
1358
+ }
1359
+ function ensureSchema(db, migrations = MIGRATIONS) {
1360
+ try {
1361
+ const current = schemaVersion(db);
1362
+ for (const m of migrations) {
1363
+ if (m.version > current) {
1364
+ db.exec(m.sql);
1365
+ db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
1243
1366
  }
1244
- writeToken(target, token);
1245
- console.log(source_default.green(`Saved to ${target}`));
1246
- } catch (err) {
1247
- emitError(err, "table");
1248
- process.exit(exitCodeFor(err));
1249
1367
  }
1368
+ } catch (err) {
1369
+ throw dbError("Schema migration failed", err);
1250
1370
  }
1251
- });
1252
-
1253
- // src/lib/time.ts
1254
- function nowUtc() {
1255
- return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
1256
- }
1257
- function formatLocal(utcStr, timezone) {
1258
- const dt = new Date(utcStr);
1259
- const parts = new Intl.DateTimeFormat("sv-SE", {
1260
- timeZone: timezone,
1261
- year: "numeric",
1262
- month: "2-digit",
1263
- day: "2-digit",
1264
- hour: "2-digit",
1265
- minute: "2-digit",
1266
- hour12: false
1267
- }).formatToParts(dt);
1268
- const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
1269
- return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`;
1270
- }
1271
- function formatLocalDate(utcStr, timezone) {
1272
- return formatLocal(utcStr, timezone).split(" ")[0];
1273
1371
  }
1274
- function todayLocal(timezone) {
1275
- return formatLocalDate(nowUtc(), timezone);
1372
+
1373
+ // src/api/token.ts
1374
+ import { readFileSync } from "fs";
1375
+ import { resolve as resolve2 } from "path";
1376
+ import { homedir as homedir2 } from "os";
1377
+ function defaultTokenPath() {
1378
+ return process.env.OURA_TOKEN_PATH ?? resolve2(homedir2(), ".oura-token");
1276
1379
  }
1277
- function getTimezoneOffsetMs(utc, tz) {
1380
+ function resolveToken(explicit, tokenPath) {
1381
+ if (explicit)
1382
+ return { token: explicit.trim(), source: "--token" };
1383
+ if (process.env.OURA_TOKEN)
1384
+ return { token: process.env.OURA_TOKEN.trim(), source: "OURA_TOKEN" };
1385
+ const path = tokenPath ?? defaultTokenPath();
1386
+ try {
1387
+ return { token: readFileSync(path, "utf-8").trim(), source: path };
1388
+ } catch {
1389
+ return { token: null, source: path };
1390
+ }
1391
+ }
1392
+
1393
+ // src/api/client.ts
1394
+ var BASE_URL = "https://api.ouraring.com/v2/usercollection";
1395
+ var MAX_PAGES = 1e4;
1396
+
1397
+ class OuraClient {
1398
+ token;
1399
+ constructor(options = {}) {
1400
+ const { token, source } = resolveToken(options.token, options.tokenPath);
1401
+ if (!token) {
1402
+ throw new CliError("TOKEN_MISSING", `No Oura access token at ${source}.`, "Run `oura-cli login` or set OURA_TOKEN.");
1403
+ }
1404
+ if (/\s/.test(token)) {
1405
+ throw new CliError("TOKEN_INVALID", `The token from ${source} contains whitespace or a line break; a token is a single line.`, "Fix the file or variable, or run `oura-cli login` again.");
1406
+ }
1407
+ this.token = token;
1408
+ }
1409
+ async fetch(endpoint, query) {
1410
+ const rows = [];
1411
+ const seenTokens = new Set;
1412
+ let nextToken = null;
1413
+ do {
1414
+ if (seenTokens.size >= MAX_PAGES) {
1415
+ throw new CliError("API_ERROR", `Oura API returned more than ${MAX_PAGES} pages for ${endpoint}; stopping.`);
1416
+ }
1417
+ const params = new URLSearchParams(query);
1418
+ if (nextToken)
1419
+ params.set("next_token", nextToken);
1420
+ const page = await this.getPage(`${BASE_URL}/${endpoint}?${params}`);
1421
+ for (const row of page.data)
1422
+ rows.push(row);
1423
+ nextToken = page.next_token;
1424
+ if (nextToken && seenTokens.has(nextToken)) {
1425
+ throw new CliError("API_ERROR", `Oura API repeated pagination token for ${endpoint}; stopping to avoid a loop.`);
1426
+ }
1427
+ if (nextToken)
1428
+ seenTokens.add(nextToken);
1429
+ } while (nextToken);
1430
+ return rows;
1431
+ }
1432
+ async getPage(url) {
1433
+ const response = await fetch(url, {
1434
+ headers: { Authorization: `Bearer ${this.token}` }
1435
+ });
1436
+ if (!response.ok) {
1437
+ const rawBody = await response.text();
1438
+ const redacted = redactSecrets(rawBody);
1439
+ const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
1440
+ if (response.status === 401 || response.status === 403) {
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
+ }
1443
+ throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
1444
+ }
1445
+ let json;
1446
+ try {
1447
+ json = await response.json();
1448
+ } catch {
1449
+ throw new CliError("API_ERROR", "Empty response body from Oura API.");
1450
+ }
1451
+ const body = json;
1452
+ return { data: body.data ?? [], next_token: body.next_token ?? null };
1453
+ }
1454
+ }
1455
+
1456
+ // src/lib/argv-normalize.ts
1457
+ var GLOBAL_FLAGS_WITH_VALUE = new Set(["--format", "--token", "--db", "--tz"]);
1458
+ var GLOBAL_FLAGS_BOOLEAN = new Set(["--no-color"]);
1459
+ var SUBCOMMANDS = new Set([
1460
+ "login",
1461
+ "describe",
1462
+ "healthcheck",
1463
+ "doctor",
1464
+ "manifest",
1465
+ "fetch",
1466
+ "sync",
1467
+ "db",
1468
+ "report"
1469
+ ]);
1470
+ function normalizeArgv(argv) {
1471
+ const [bun, script, ...rest] = argv;
1472
+ const subIdx = rest.findIndex((a) => SUBCOMMANDS.has(a));
1473
+ if (subIdx < 0)
1474
+ return argv;
1475
+ const before = rest.slice(0, subIdx);
1476
+ const subcommandOnwards = rest.slice(subIdx);
1477
+ const hoisted = [];
1478
+ const leftover = [];
1479
+ for (let i = 0;i < before.length; i++) {
1480
+ const tok = before[i];
1481
+ if (tok.includes("=")) {
1482
+ const name = tok.slice(0, tok.indexOf("="));
1483
+ if (GLOBAL_FLAGS_WITH_VALUE.has(name) || GLOBAL_FLAGS_BOOLEAN.has(name)) {
1484
+ hoisted.push(tok);
1485
+ continue;
1486
+ }
1487
+ }
1488
+ if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
1489
+ hoisted.push(tok);
1490
+ if (i + 1 < before.length) {
1491
+ hoisted.push(before[i + 1]);
1492
+ i++;
1493
+ }
1494
+ continue;
1495
+ }
1496
+ if (GLOBAL_FLAGS_BOOLEAN.has(tok)) {
1497
+ hoisted.push(tok);
1498
+ continue;
1499
+ }
1500
+ leftover.push(tok);
1501
+ }
1502
+ const dd = subcommandOnwards.indexOf("--");
1503
+ if (dd >= 0) {
1504
+ return [bun, script, ...leftover, ...subcommandOnwards.slice(0, dd), ...hoisted, ...subcommandOnwards.slice(dd)];
1505
+ }
1506
+ return [bun, script, ...leftover, ...subcommandOnwards, ...hoisted];
1507
+ }
1508
+ function isVersionRequest(rawArgs) {
1509
+ for (let i = 0;i < rawArgs.length; i++) {
1510
+ const tok = rawArgs[i];
1511
+ if (SUBCOMMANDS.has(tok))
1512
+ return false;
1513
+ if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
1514
+ i++;
1515
+ continue;
1516
+ }
1517
+ if (tok === "--version" || tok === "-v")
1518
+ return true;
1519
+ }
1520
+ return false;
1521
+ }
1522
+
1523
+ // src/lib/format-resolve.ts
1524
+ function resolveFormat({ explicit, isTty }) {
1525
+ if (explicit === undefined)
1526
+ return isTty ? "table" : "json";
1527
+ if (explicit === "table" || explicit === "json")
1528
+ return explicit;
1529
+ throw new CliError("BAD_ARGS", `Unknown --format value: "${explicit}". Use "table" or "json".`);
1530
+ }
1531
+ function formatFromArgv(argv, isTty) {
1532
+ let explicit;
1533
+ for (let i = 0;i < argv.length; i++) {
1534
+ const a = argv[i];
1535
+ if (a === "--format") {
1536
+ explicit = argv[i + 1];
1537
+ i++;
1538
+ } else if (a.startsWith("--format="))
1539
+ explicit = a.slice("--format=".length);
1540
+ else if (GLOBAL_FLAGS_WITH_VALUE.has(a))
1541
+ i++;
1542
+ }
1543
+ try {
1544
+ return resolveFormat({ explicit, isTty });
1545
+ } catch {
1546
+ return isTty ? "table" : "json";
1547
+ }
1548
+ }
1549
+
1550
+ // src/lib/time.ts
1551
+ function nowUtc() {
1552
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
1553
+ }
1554
+ function formatLocal(utcStr, timezone) {
1555
+ const dt = new Date(utcStr);
1556
+ const parts = new Intl.DateTimeFormat("sv-SE", {
1557
+ timeZone: timezone,
1558
+ year: "numeric",
1559
+ month: "2-digit",
1560
+ day: "2-digit",
1561
+ hour: "2-digit",
1562
+ minute: "2-digit",
1563
+ hour12: false
1564
+ }).formatToParts(dt);
1565
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
1566
+ return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}`;
1567
+ }
1568
+ function formatLocalDate(utcStr, timezone) {
1569
+ return formatLocal(utcStr, timezone).split(" ")[0];
1570
+ }
1571
+ function todayLocal(timezone) {
1572
+ return formatLocalDate(nowUtc(), timezone);
1573
+ }
1574
+ function getTimezoneOffsetMs(utc, tz) {
1278
1575
  const tzPart = new Intl.DateTimeFormat("en-US", {
1279
1576
  timeZone: tz,
1280
1577
  timeZoneName: "longOffset"
@@ -1324,72 +1621,254 @@ function isCalendarDate(value) {
1324
1621
  return !Number.isNaN(ms) && new Date(ms).toISOString().slice(0, 10) === value;
1325
1622
  }
1326
1623
 
1327
- // src/collections/types.ts
1328
- function defineCollection(c) {
1329
- return c;
1624
+ // src/lib/validate.ts
1625
+ function assertCalendarDate(value, label) {
1626
+ if (!isCalendarDate(value)) {
1627
+ throw new CliError("BAD_ARGS", `${label} must be a real YYYY-MM-DD date, got "${value}".`);
1628
+ }
1629
+ return value;
1630
+ }
1631
+ function assertPositiveInt(value, label) {
1632
+ const n = Number(value);
1633
+ if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(n) || n < 1) {
1634
+ throw new CliError("BAD_ARGS", `${label} must be a positive integer, got "${value}".`);
1635
+ }
1636
+ return n;
1637
+ }
1638
+ function assertTimezone(tz) {
1639
+ try {
1640
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
1641
+ return tz;
1642
+ } catch {
1643
+ throw new CliError("BAD_ARGS", `Unknown timezone "${tz}".`, "Use an IANA name such as Europe/Berlin (env: OURA_TZ, flag: --tz).");
1644
+ }
1330
1645
  }
1331
1646
 
1332
- // src/collections/sleep.ts
1333
- var sleep = defineCollection({
1334
- name: "sleep",
1335
- endpoint: "daily_sleep",
1336
- table: "daily_sleep",
1337
- description: "Daily sleep score and contributors",
1338
- conflict: "replace",
1339
- rangeParams: "date",
1340
- identity: [
1341
- { field: "id", description: "Oura record id" },
1342
- { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
1343
- ],
1344
- columns: [
1345
- { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
1346
- { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
1347
- { name: "score", type: "INTEGER", pick: (r) => r.score },
1348
- { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
1349
- { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
1350
- ]
1351
- });
1352
-
1353
- // src/collections/readiness.ts
1354
- var readiness = defineCollection({
1355
- name: "readiness",
1356
- endpoint: "daily_readiness",
1357
- table: "daily_readiness",
1358
- description: "Daily readiness score, contributors and temperature deviation",
1359
- conflict: "replace",
1360
- rangeParams: "date",
1361
- identity: [
1362
- { field: "id", description: "Oura record id" },
1363
- { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
1364
- ],
1365
- columns: [
1366
- { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
1367
- { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
1368
- { name: "score", type: "INTEGER", pick: (r) => r.score },
1369
- { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
1370
- { name: "temperature_deviation", type: "REAL", pick: (r) => r.temperature_deviation },
1371
- { name: "temperature_trend_deviation", type: "REAL", pick: (r) => r.temperature_trend_deviation },
1372
- { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
1373
- ]
1374
- });
1375
-
1376
- // src/collections/activity.ts
1377
- var activity = defineCollection({
1378
- name: "activity",
1379
- endpoint: "daily_activity",
1380
- table: "daily_activity",
1381
- description: "Daily activity score, steps, calories and activity-time buckets",
1382
- conflict: "replace",
1383
- rangeParams: "date",
1384
- identity: [
1385
- { field: "id", description: "Oura record id" },
1386
- { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
1387
- ],
1388
- columns: [
1389
- { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
1390
- { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
1391
- { name: "score", type: "INTEGER", pick: (r) => r.score },
1392
- { name: "active_calories", type: "INTEGER", pick: (r) => r.active_calories },
1647
+ // src/commands/run-command.ts
1648
+ var processIo = {
1649
+ stdout: (s) => {
1650
+ process.stdout.write(s + `
1651
+ `);
1652
+ },
1653
+ stderr: (s) => {
1654
+ process.stderr.write(s + `
1655
+ `);
1656
+ },
1657
+ exit: (code) => process.exit(code),
1658
+ isTty: process.stdout.isTTY === true
1659
+ };
1660
+ var camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1661
+ function assertKnownArgs(declared, args) {
1662
+ const known = new Set(["_"]);
1663
+ let positionals = 0;
1664
+ for (const [name, def] of Object.entries(declared)) {
1665
+ known.add(name);
1666
+ known.add(camel(name));
1667
+ if (name.startsWith("no-"))
1668
+ known.add(name.slice(3));
1669
+ for (const alias of [def.alias ?? []].flat())
1670
+ known.add(alias);
1671
+ if (def.type === "positional")
1672
+ positionals++;
1673
+ }
1674
+ const unknown = Object.keys(args).filter((k) => !known.has(k));
1675
+ if (unknown.length > 0) {
1676
+ const flags = unknown.map((f) => f.length === 1 ? `-${f}` : `--${f}`).join(", ");
1677
+ 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.";
1678
+ throw new CliError("BAD_ARGS", `Unknown flag${unknown.length > 1 ? "s" : ""}: ${flags}.`, hint);
1679
+ }
1680
+ const extra = (args._ ?? []).slice(positionals);
1681
+ if (extra.length > 0) {
1682
+ throw new CliError("BAD_ARGS", `Unexpected argument${extra.length > 1 ? "s" : ""}: ${extra.join(" ")}.`, "Run the command with --help to see its arguments.");
1683
+ }
1684
+ }
1685
+ async function execute(def, args, io = processIo) {
1686
+ if (args["no-color"] === true || args.color === false || process.env.NO_COLOR)
1687
+ source_default.level = 0;
1688
+ let db;
1689
+ let format = io.isTty ? "table" : "json";
1690
+ let exitCode = 0;
1691
+ try {
1692
+ format = resolveFormat({ explicit: args.format, isTty: io.isTty });
1693
+ assertKnownArgs({ ...commonArgs, ...def.args ?? {} }, args);
1694
+ const outputFormat = def.jsonOnly ? "json" : format;
1695
+ const tz = assertTimezone(args.tz ?? resolveDefaultTimezone());
1696
+ const ctx = { format: outputFormat, tz, today: today(tz) };
1697
+ if (def.needs?.db) {
1698
+ db = openDatabase(args.db);
1699
+ ensureSchema(db);
1700
+ ctx.db = db;
1701
+ }
1702
+ if (def.needs?.client) {
1703
+ ctx.client = new OuraClient(args.token ? { token: args.token } : {});
1704
+ }
1705
+ const out = await def.run(ctx, args);
1706
+ io.stdout(outputFormat === "json" ? JSON.stringify(out.json, null, 2) : out.text());
1707
+ exitCode = out.exitCode ?? 0;
1708
+ } catch (raw) {
1709
+ const err = asDbError(raw) ?? raw;
1710
+ io.stderr(formatError(err, format).text);
1711
+ exitCode = exitCodeFor(err);
1712
+ } finally {
1713
+ db?.close();
1714
+ }
1715
+ if (exitCode !== 0)
1716
+ io.exit(exitCode);
1717
+ }
1718
+ function dataCommand(def) {
1719
+ return defineCommand({
1720
+ meta: def.meta,
1721
+ args: { ...commonArgs, ...def.args ?? {} },
1722
+ run: ({ args }) => execute(def, args, processIo)
1723
+ });
1724
+ }
1725
+
1726
+ // src/commands/login.ts
1727
+ async function readHiddenToken(input, output) {
1728
+ if (!input.isTTY || !input.setRawMode) {
1729
+ throw new CliError("BAD_ARGS", "Interactive login requires a terminal.", "Use `oura-cli login --token` for non-interactive use.");
1730
+ }
1731
+ output.write("Paste your token (input hidden): ");
1732
+ input.setRawMode(true);
1733
+ input.resume();
1734
+ return new Promise((resolve, reject) => {
1735
+ let token = "";
1736
+ const finish = () => {
1737
+ input.off("data", onData);
1738
+ input.setRawMode?.(false);
1739
+ input.pause();
1740
+ output.write(`
1741
+ `);
1742
+ };
1743
+ const onData = (chunk) => {
1744
+ for (const char of chunk.toString("utf8")) {
1745
+ if (char === "\r" || char === `
1746
+ `) {
1747
+ finish();
1748
+ resolve(token);
1749
+ return;
1750
+ }
1751
+ if (char === "\x03" || char === "\x04") {
1752
+ finish();
1753
+ reject(new CliError("BAD_ARGS", "Login cancelled."));
1754
+ return;
1755
+ }
1756
+ if (char === "\b" || char === "\x7F") {
1757
+ token = token.slice(0, -1);
1758
+ } else if (char >= " ") {
1759
+ token += char;
1760
+ }
1761
+ }
1762
+ };
1763
+ input.on("data", onData);
1764
+ });
1765
+ }
1766
+ function writeToken(path, token) {
1767
+ const trimmed = token.trim();
1768
+ if (trimmed.length === 0) {
1769
+ throw new CliError("BAD_ARGS", "Token cannot be empty.");
1770
+ }
1771
+ mkdirSync2(dirname2(path), { recursive: true });
1772
+ writeFileSync(path, trimmed, { encoding: "utf-8" });
1773
+ if (process.platform !== "win32") {
1774
+ chmodSync2(path, 384);
1775
+ }
1776
+ }
1777
+ var loginCommand = defineCommand({
1778
+ meta: { name: "login", description: "Save an Oura Personal Access Token for future commands." },
1779
+ args: {
1780
+ ...commonArgs,
1781
+ token: { type: "string", description: "Pass token non-interactively (e.g. for scripts)" },
1782
+ path: { type: "string", description: "Where to save the token (default: $OURA_TOKEN_PATH or ~/.oura-token)" }
1783
+ },
1784
+ async run({ args }) {
1785
+ try {
1786
+ assertKnownArgs({ ...commonArgs, token: {}, path: {} }, args);
1787
+ if (args["no-color"] || process.env.NO_COLOR) {
1788
+ await Promise.resolve().then(() => init_source());
1789
+ source_default.level = 0;
1790
+ }
1791
+ const target = args.path ?? process.env.OURA_TOKEN_PATH ?? resolve3(homedir3(), ".oura-token");
1792
+ let token = args.token;
1793
+ if (!token) {
1794
+ console.log("Get a Personal Access Token at https://cloud.ouraring.com/personal-access-tokens");
1795
+ token = await readHiddenToken(process.stdin, process.stdout);
1796
+ }
1797
+ writeToken(target, token);
1798
+ console.log(source_default.green(`Saved to ${target}`));
1799
+ } catch (err) {
1800
+ emitError(err, "table");
1801
+ process.exit(exitCodeFor(err));
1802
+ }
1803
+ }
1804
+ });
1805
+
1806
+ // src/collections/types.ts
1807
+ function defineCollection(c) {
1808
+ return c;
1809
+ }
1810
+
1811
+ // src/collections/sleep.ts
1812
+ var sleep = defineCollection({
1813
+ name: "sleep",
1814
+ endpoint: "daily_sleep",
1815
+ table: "daily_sleep",
1816
+ description: "Daily sleep score and contributors",
1817
+ conflict: "replace",
1818
+ rangeParams: "date",
1819
+ identity: [
1820
+ { field: "id", description: "Oura record id" },
1821
+ { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
1822
+ ],
1823
+ columns: [
1824
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
1825
+ { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
1826
+ { name: "score", type: "INTEGER", pick: (r) => r.score },
1827
+ { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
1828
+ { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
1829
+ ]
1830
+ });
1831
+
1832
+ // src/collections/readiness.ts
1833
+ var readiness = defineCollection({
1834
+ name: "readiness",
1835
+ endpoint: "daily_readiness",
1836
+ table: "daily_readiness",
1837
+ description: "Daily readiness score, contributors and temperature deviation",
1838
+ conflict: "replace",
1839
+ rangeParams: "date",
1840
+ identity: [
1841
+ { field: "id", description: "Oura record id" },
1842
+ { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
1843
+ ],
1844
+ columns: [
1845
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
1846
+ { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
1847
+ { name: "score", type: "INTEGER", pick: (r) => r.score },
1848
+ { name: "contributors", type: "TEXT", pick: (r) => JSON.stringify(r.contributors) },
1849
+ { name: "temperature_deviation", type: "REAL", pick: (r) => r.temperature_deviation },
1850
+ { name: "temperature_trend_deviation", type: "REAL", pick: (r) => r.temperature_trend_deviation },
1851
+ { name: "timestamp", type: "TEXT", pick: (r) => r.timestamp }
1852
+ ]
1853
+ });
1854
+
1855
+ // src/collections/activity.ts
1856
+ var activity = defineCollection({
1857
+ name: "activity",
1858
+ endpoint: "daily_activity",
1859
+ table: "daily_activity",
1860
+ description: "Daily activity score, steps, calories and activity-time buckets",
1861
+ conflict: "replace",
1862
+ rangeParams: "date",
1863
+ identity: [
1864
+ { field: "id", description: "Oura record id" },
1865
+ { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
1866
+ ],
1867
+ columns: [
1868
+ { name: "id", type: "TEXT", pk: true, pick: (r) => r.id },
1869
+ { name: "day", type: "TEXT", unique: true, pick: (r) => r.day },
1870
+ { name: "score", type: "INTEGER", pick: (r) => r.score },
1871
+ { name: "active_calories", type: "INTEGER", pick: (r) => r.active_calories },
1393
1872
  { name: "steps", type: "INTEGER", pick: (r) => r.steps },
1394
1873
  { name: "equivalent_walking_distance", type: "REAL", pick: (r) => r.equivalent_walking_distance },
1395
1874
  { name: "high_activity_time", type: "INTEGER", pick: (r) => r.high_activity_time },
@@ -1475,6 +1954,7 @@ var workout = defineCollection({
1475
1954
  description: "Workout sessions with activity, calories, distance and intensity",
1476
1955
  conflict: "replace",
1477
1956
  rangeParams: "date",
1957
+ dayRangeOffset: [0, 1],
1478
1958
  identity: [
1479
1959
  { field: "id", description: "Oura record id" },
1480
1960
  { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
@@ -1501,6 +1981,7 @@ var sleepPeriods = defineCollection({
1501
1981
  description: "Individual sleep periods with stages, HRV, heart rate and efficiency",
1502
1982
  conflict: "replace",
1503
1983
  rangeParams: "date",
1984
+ dayRangeOffset: [-1, 0],
1504
1985
  identity: [
1505
1986
  { field: "id", description: "Oura record id" },
1506
1987
  { field: "day", format: "date", description: "Date the record applies to (YYYY-MM-DD)" }
@@ -1575,13 +2056,14 @@ function rowValues(c, row) {
1575
2056
  return c.columns.map((col) => col.pick(row));
1576
2057
  }
1577
2058
  var MS_PER_DAY = 86400000;
1578
- function dateQueries(start, end, maxDays) {
2059
+ function dateQueries(start, end, maxDays, offset) {
2060
+ const query = (s, e) => ({ start_date: shiftDay(s, offset[0]), end_date: shiftDay(e, offset[1]) });
1579
2061
  if (!maxDays)
1580
- return [{ start_date: start, end_date: end }];
2062
+ return [query(start, end)];
1581
2063
  const out = [];
1582
2064
  for (let s = start;s <= end; s = shiftDay(s, maxDays)) {
1583
2065
  const e = shiftDay(s, maxDays - 1);
1584
- out.push({ start_date: s, end_date: e < end ? e : end });
2066
+ out.push(query(s, e < end ? e : end));
1585
2067
  }
1586
2068
  return out;
1587
2069
  }
@@ -1598,7 +2080,7 @@ function datetimeQueries(start, end, tz, maxDays) {
1598
2080
  function rangeQueries(c, start, end, tz) {
1599
2081
  if (start > end)
1600
2082
  return [];
1601
- return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays) : datetimeQueries(start, end, tz, c.maxRangeDays);
2083
+ return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
1602
2084
  }
1603
2085
  async function fetchCollection(client, c, start, end, tz) {
1604
2086
  const rows = [];
@@ -1610,7 +2092,7 @@ async function fetchCollection(client, c, start, end, tz) {
1610
2092
  }
1611
2093
 
1612
2094
  // src/commands/describe.ts
1613
- var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json" };
2095
+ var OUTPUT_SCHEMAS = { doctor: "docs/schemas/doctor.json", describe: "docs/schemas/describe.json" };
1614
2096
  var ENUM_ARGS = {
1615
2097
  fetch: { collection: names() },
1616
2098
  report: { period: ["week", "month"] }
@@ -1700,8 +2182,9 @@ function buildManifest(version, commands) {
1700
2182
  function describeCommand(version, getCommands) {
1701
2183
  return defineCommand({
1702
2184
  meta: { name: "describe", description: "Emit a machine-readable manifest of commands, args, and outputs." },
1703
- args: {},
1704
- run() {
2185
+ args: { ...commonArgs },
2186
+ run({ args }) {
2187
+ assertKnownArgs(commonArgs, args);
1705
2188
  console.log(JSON.stringify(buildManifest(version, getCommands()), null, 2));
1706
2189
  }
1707
2190
  });
@@ -1709,33 +2192,41 @@ function describeCommand(version, getCommands) {
1709
2192
 
1710
2193
  // src/db/sync.ts
1711
2194
  var BACKFILL_DAYS = 30;
1712
- var FRESHNESS_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
1713
- async function importDaily(db, client, clock, log) {
2195
+ function lastDay(db, table) {
2196
+ return db.query(`SELECT MAX(day) AS d FROM ${table}`).get().d;
2197
+ }
2198
+ function rowCount(db, table) {
2199
+ return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
2200
+ }
2201
+ async function importDaily(db, client, clock, log, window = {}) {
1714
2202
  const { today, tz } = clock;
1715
2203
  const _log = log ?? (() => {});
1716
- const lastDates = [];
1717
- for (const tbl of FRESHNESS_TABLES) {
1718
- const row = db.query(`SELECT MAX(day) as d FROM ${tbl}`).get();
1719
- if (row?.d)
1720
- lastDates.push(row.d);
1721
- }
1722
- const isFirstSync = lastDates.length === 0;
1723
- const startDate = isFirstSync ? shiftDay(today, -BACKFILL_DAYS) : lastDates.sort()[0];
1724
- _log(isFirstSync ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${today}` : `Syncing ${startDate} \u2192 ${today}`);
1725
- const counts = {};
1726
- for (const c of COLLECTIONS) {
1727
- const rows = await fetchCollection(client, c, startDate, today, tz);
2204
+ const end = window.to ?? today;
2205
+ const backfillStart = shiftDay(end, -(BACKFILL_DAYS - 1));
2206
+ const plan = COLLECTIONS.map((c) => {
2207
+ const last = lastDay(db, c.table);
2208
+ return { c, last, start: window.from ?? last ?? backfillStart };
2209
+ });
2210
+ const isFirstSync = plan.every((p) => p.last === null);
2211
+ const startDate = plan.map((p) => p.start).sort()[0];
2212
+ _log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
2213
+ const fetched = {};
2214
+ const added = {};
2215
+ for (const { c, start } of plan) {
2216
+ const rows = await fetchCollection(client, c, start, end, tz);
2217
+ const before = rowCount(db, c.table);
1728
2218
  const stmt = db.query(insertSql(c));
1729
2219
  db.transaction((rs) => {
1730
2220
  for (const r of rs)
1731
2221
  stmt.run(...rowValues(c, r));
1732
2222
  })(rows);
1733
- counts[c.table] = rows.length;
2223
+ fetched[c.table] = rows.length;
2224
+ added[c.table] = rowCount(db, c.table) - before;
1734
2225
  if (rows.length > 0)
1735
- _log(` + ${c.table}: ${rows.length} rows`);
2226
+ _log(` + ${c.table}: ${rows.length} fetched, ${added[c.table]} new`);
1736
2227
  }
1737
2228
  _log("Import complete.");
1738
- return { startDate, endDate: today, counts, isFirstSync };
2229
+ return { startDate, endDate: end, fetched, added, isFirstSync };
1739
2230
  }
1740
2231
 
1741
2232
  // src/db/queries.ts
@@ -1764,7 +2255,7 @@ function getDaySummary(db, day) {
1764
2255
  };
1765
2256
  }
1766
2257
  function getTrends(db, days, today) {
1767
- const start = shiftDay(today, -days);
2258
+ const start = shiftDay(today, -(days - 1));
1768
2259
  const results = [];
1769
2260
  const metrics = [
1770
2261
  ["Sleep Score", "daily_sleep", "score"],
@@ -1780,498 +2271,170 @@ function getTrends(db, days, today) {
1780
2271
  }
1781
2272
  }
1782
2273
  const sp = db.query("SELECT AVG(spo2_average) as avg, MIN(spo2_average) as min, MAX(spo2_average) as max, COUNT(*) as count FROM daily_spo2 WHERE day BETWEEN ? AND ?").get(start, today);
1783
- if (sp.count > 0 && sp.avg !== null) {
1784
- results.push({ label: "SpO2", avg: +sp.avg.toFixed(1), min: +sp.min.toFixed(1), max: +sp.max.toFixed(1), count: sp.count });
1785
- }
1786
- return results;
1787
- }
1788
- function getStats(db, today) {
1789
- const tables = COLLECTIONS.map((c) => {
1790
- const row = db.query(`SELECT COUNT(*) as cnt FROM ${c.table}`).get();
1791
- return { table: c.table, rows: row.cnt };
1792
- });
1793
- const range = db.query("SELECT MIN(day) as first, MAX(day) as last FROM daily_sleep").get();
1794
- const trends = getTrends(db, 99999, today);
1795
- const mostSteps = db.query("SELECT day, steps FROM daily_activity WHERE steps IS NOT NULL ORDER BY steps DESC LIMIT 1").get();
1796
- const bestSleep = db.query("SELECT day, score FROM daily_sleep WHERE score IS NOT NULL ORDER BY score DESC LIMIT 1").get();
1797
- return {
1798
- tables,
1799
- dateRange: range,
1800
- trends,
1801
- records: {
1802
- mostSteps: mostSteps ?? null,
1803
- bestSleep: bestSleep ?? null
1804
- }
1805
- };
1806
- }
1807
-
1808
- // src/render/format.ts
1809
- init_source();
1810
- function scoreColor(score) {
1811
- if (score === null)
1812
- return source_default.gray("\u2014");
1813
- if (score >= 85)
1814
- return source_default.green(String(score));
1815
- if (score >= 70)
1816
- return source_default.yellow(String(score));
1817
- return source_default.red(String(score));
1818
- }
1819
- function fmtHours(h) {
1820
- if (h === null)
1821
- return source_default.gray("\u2014");
1822
- return `${h}h`;
1823
- }
1824
- function isEmptyDay(s) {
1825
- return s.sleep_score === null && s.readiness_score === null && s.activity_score === null && s.steps === null && s.stress === null && s.spo2 === null && s.temp_deviation === null && s.sleep_hours === null && s.deep_hours === null && s.rem_hours === null && s.avg_hrv === null && s.lowest_hr === null && s.efficiency === null;
1826
- }
1827
- function formatDaySummary(summary, format, emptyHint) {
1828
- if (format === "json")
1829
- return JSON.stringify(summary, null, 2);
1830
- if (emptyHint && isEmptyDay(summary)) {
1831
- return [
1832
- "",
1833
- source_default.bold(` ${summary.day}`),
1834
- source_default.gray("\u2500".repeat(50)),
1835
- ` No Oura data for ${summary.day} yet.`,
1836
- ` ${emptyHint}`
1837
- ].join(`
1838
- `);
1839
- }
1840
- const lines = [
1841
- "",
1842
- source_default.bold(` ${summary.day}`),
1843
- source_default.gray("\u2500".repeat(50)),
1844
- ` Sleep: ${scoreColor(summary.sleep_score)} Readiness: ${scoreColor(summary.readiness_score)} Activity: ${scoreColor(summary.activity_score)}`,
1845
- ` Steps: ${summary.steps ?? source_default.gray("\u2014")}`
1846
- ];
1847
- if (summary.spo2 !== null)
1848
- lines.push(` SpO2: ${summary.spo2}%`);
1849
- if (summary.temp_deviation !== null) {
1850
- const sign = summary.temp_deviation >= 0 ? "+" : "";
1851
- lines.push(` Temp: ${sign}${summary.temp_deviation}\xB0C`);
1852
- }
1853
- if (summary.stress)
1854
- lines.push(` Stress: ${summary.stress}`);
1855
- if (summary.sleep_hours !== null) {
1856
- lines.push("");
1857
- lines.push(` Sleep: ${fmtHours(summary.sleep_hours)} total | ${fmtHours(summary.deep_hours)} deep | ${fmtHours(summary.rem_hours)} REM`);
1858
- lines.push(` HRV: ${summary.avg_hrv ?? "\u2014"} Lowest HR: ${summary.lowest_hr ?? "\u2014"} Efficiency: ${summary.efficiency ?? "\u2014"}%`);
1859
- }
1860
- return lines.join(`
1861
- `);
1862
- }
1863
- function formatImportSummary(result) {
1864
- const c = result.counts;
1865
- return [
1866
- ` Imported ${result.startDate} \u2192 ${result.endDate}:`,
1867
- ` sleep ${c.daily_sleep ?? 0} readiness ${c.daily_readiness ?? 0} activity ${c.daily_activity ?? 0} sleep periods ${c.sleep_model ?? 0}`,
1868
- ` spo2 ${c.daily_spo2 ?? 0} stress ${c.daily_stress ?? 0} workouts ${c.workouts ?? 0} heart rate ${c.heartrate ?? 0} cardiovascular age ${c.cardiovascular_age ?? 0}`
1869
- ].join(`
1870
- `);
1871
- }
1872
- function formatWeekTable(days, format, emptyHint) {
1873
- if (format === "json")
1874
- return JSON.stringify(days, null, 2);
1875
- if (emptyHint && days.length > 0 && days.every(isEmptyDay)) {
1876
- return [
1877
- "",
1878
- " No Oura data for the last 7 days yet.",
1879
- ` ${emptyHint}`
1880
- ].join(`
1881
- `);
1882
- }
1883
- const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
1884
- const sep = source_default.gray("\u2500".repeat(56));
1885
- const rows = days.map((d) => `${d.day.padEnd(12)} ${scoreColor(d.sleep_score).padStart(6)} ${scoreColor(d.readiness_score).padStart(6)} ` + `${scoreColor(d.activity_score).padStart(9)} ${String(d.steps ?? "\u2014").padStart(7)} ${(d.stress ?? "\u2014").padEnd(10)}`);
1886
- return [`
1887
- Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
1888
- `);
1889
- }
1890
- function formatTrends(trends, days, format) {
1891
- if (format === "json")
1892
- return JSON.stringify(trends, null, 2);
1893
- const lines = [
1894
- "",
1895
- source_default.bold(` Trends: last ${days} days`),
1896
- source_default.gray("\u2500".repeat(50))
1897
- ];
1898
- for (const t of trends) {
1899
- 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)`);
1900
- }
1901
- return lines.join(`
1902
- `);
1903
- }
1904
- function formatStats(stats, format) {
1905
- if (format === "json")
1906
- return JSON.stringify(stats, null, 2);
1907
- const lines = [
1908
- "",
1909
- source_default.bold(" Database Statistics"),
1910
- source_default.gray("\u2550".repeat(50))
1911
- ];
1912
- for (const t of stats.tables) {
1913
- lines.push(` ${t.table.padEnd(22)} ${String(t.rows).padStart(8)} rows`);
1914
- }
1915
- if (stats.dateRange.first) {
1916
- lines.push(`
1917
- Date range: ${stats.dateRange.first} \u2192 ${stats.dateRange.last}`);
1918
- }
1919
- for (const t of stats.trends) {
1920
- lines.push(` ${t.label.padEnd(15)} avg: ${String(t.avg).padStart(5)} min: ${String(t.min).padStart(5)} max: ${String(t.max).padStart(5)}`);
1921
- }
1922
- if (stats.records.mostSteps) {
1923
- lines.push(`
1924
- Most steps: ${stats.records.mostSteps.steps} on ${stats.records.mostSteps.day}`);
1925
- }
1926
- if (stats.records.bestSleep) {
1927
- lines.push(` Best sleep: ${stats.records.bestSleep.score} on ${stats.records.bestSleep.day}`);
1928
- }
1929
- return lines.join(`
1930
- `);
1931
- }
1932
-
1933
- // src/commands/run-command.ts
1934
- init_source();
1935
-
1936
- // src/db/open.ts
1937
- import { Database } from "bun:sqlite";
1938
- import { resolve as resolve2, dirname as dirname2 } from "path";
1939
- import { homedir as homedir2 } from "os";
1940
- import { mkdirSync as mkdirSync2 } from "fs";
1941
-
1942
- // src/db/migrations.ts
1943
- var MIGRATIONS = [
1944
- {
1945
- version: 1,
1946
- sql: `
1947
- CREATE TABLE IF NOT EXISTS daily_sleep (
1948
- id TEXT PRIMARY KEY,
1949
- day TEXT UNIQUE,
1950
- score INTEGER,
1951
- contributors TEXT,
1952
- timestamp TEXT
1953
- );
1954
- CREATE TABLE IF NOT EXISTS daily_readiness (
1955
- id TEXT PRIMARY KEY,
1956
- day TEXT UNIQUE,
1957
- score INTEGER,
1958
- contributors TEXT,
1959
- temperature_deviation REAL,
1960
- temperature_trend_deviation REAL,
1961
- timestamp TEXT
1962
- );
1963
- CREATE TABLE IF NOT EXISTS daily_activity (
1964
- id TEXT PRIMARY KEY,
1965
- day TEXT UNIQUE,
1966
- score INTEGER,
1967
- active_calories INTEGER,
1968
- steps INTEGER,
1969
- equivalent_walking_distance REAL,
1970
- high_activity_time INTEGER,
1971
- medium_activity_time INTEGER,
1972
- low_activity_time INTEGER,
1973
- sedentary_time INTEGER,
1974
- total_calories INTEGER,
1975
- target_calories INTEGER,
1976
- contributors TEXT,
1977
- timestamp TEXT
1978
- );
1979
- CREATE TABLE IF NOT EXISTS daily_spo2 (
1980
- id TEXT PRIMARY KEY,
1981
- day TEXT UNIQUE,
1982
- spo2_average REAL,
1983
- breathing_disturbance_index REAL
1984
- );
1985
- CREATE TABLE IF NOT EXISTS daily_stress (
1986
- id TEXT PRIMARY KEY,
1987
- day TEXT UNIQUE,
1988
- day_summary TEXT,
1989
- recovery_high INTEGER,
1990
- stress_high INTEGER
1991
- );
1992
- CREATE TABLE IF NOT EXISTS heartrate (
1993
- timestamp TEXT,
1994
- bpm INTEGER,
1995
- source TEXT,
1996
- day TEXT
1997
- );
1998
- CREATE INDEX IF NOT EXISTS idx_heartrate_ts ON heartrate(timestamp);
1999
- CREATE UNIQUE INDEX IF NOT EXISTS idx_heartrate_unique ON heartrate(timestamp, source);
2000
- CREATE INDEX IF NOT EXISTS idx_heartrate_day ON heartrate(day);
2001
- CREATE TABLE IF NOT EXISTS vo2max (
2002
- id TEXT PRIMARY KEY,
2003
- day TEXT UNIQUE,
2004
- vo2_max REAL,
2005
- timestamp TEXT
2006
- );
2007
- CREATE TABLE IF NOT EXISTS cardiovascular_age (
2008
- id TEXT PRIMARY KEY,
2009
- day TEXT UNIQUE,
2010
- vascular_age INTEGER
2011
- );
2012
- CREATE TABLE IF NOT EXISTS workouts (
2013
- id TEXT PRIMARY KEY,
2014
- day TEXT,
2015
- activity TEXT,
2016
- calories REAL,
2017
- distance REAL,
2018
- start_datetime TEXT,
2019
- end_datetime TEXT,
2020
- intensity TEXT,
2021
- label TEXT,
2022
- source TEXT
2023
- );
2024
- CREATE TABLE IF NOT EXISTS sleep_model (
2025
- id TEXT PRIMARY KEY,
2026
- day TEXT,
2027
- average_breath REAL,
2028
- average_heart_rate REAL,
2029
- average_hrv REAL,
2030
- awake_time INTEGER,
2031
- bedtime_end TEXT,
2032
- bedtime_start TEXT,
2033
- deep_sleep_duration INTEGER,
2034
- efficiency INTEGER,
2035
- latency INTEGER,
2036
- light_sleep_duration INTEGER,
2037
- lowest_heart_rate INTEGER,
2038
- period INTEGER,
2039
- rem_sleep_duration INTEGER,
2040
- restless_periods INTEGER,
2041
- time_in_bed INTEGER,
2042
- total_sleep_duration INTEGER,
2043
- type TEXT
2044
- );
2045
- `
2046
- },
2047
- {
2048
- version: 2,
2049
- sql: `
2050
- CREATE VIEW IF NOT EXISTS v_weekly_sleep AS
2051
- SELECT strftime('%Y-W%W', day) as week, ROUND(AVG(score),1) as avg_score, COUNT(*) as days
2052
- FROM daily_sleep WHERE score IS NOT NULL GROUP BY week ORDER BY week DESC;
2053
-
2054
- CREATE VIEW IF NOT EXISTS v_weekly_readiness AS
2055
- SELECT strftime('%Y-W%W', day) as week, ROUND(AVG(score),1) as avg_score,
2056
- ROUND(AVG(temperature_deviation),2) as avg_temp_dev, COUNT(*) as days
2057
- FROM daily_readiness WHERE score IS NOT NULL GROUP BY week ORDER BY week DESC;
2058
-
2059
- CREATE VIEW IF NOT EXISTS v_weekly_activity AS
2060
- SELECT strftime('%Y-W%W', day) as week, ROUND(AVG(score),1) as avg_score,
2061
- SUM(steps) as total_steps, SUM(active_calories) as total_active_cal, COUNT(*) as days
2062
- FROM daily_activity WHERE score IS NOT NULL GROUP BY week ORDER BY week DESC;
2063
-
2064
- CREATE VIEW IF NOT EXISTS v_sleep_detail AS
2065
- SELECT day, ROUND(total_sleep_duration/3600.0,1) as sleep_hours,
2066
- ROUND(deep_sleep_duration/3600.0,1) as deep_hours,
2067
- ROUND(rem_sleep_duration/3600.0,1) as rem_hours,
2068
- average_hrv, average_heart_rate as avg_hr, lowest_heart_rate as lowest_hr, efficiency
2069
- FROM sleep_model ORDER BY day DESC;
2070
- `
2071
- }
2072
- ];
2073
-
2074
- // src/db/open.ts
2075
- function getDbPath(explicit) {
2076
- if (explicit)
2077
- return explicit;
2078
- if (process.env.OURA_DB_PATH)
2079
- return process.env.OURA_DB_PATH;
2080
- return resolve2(homedir2(), ".oura-cli", "oura.db");
2081
- }
2082
- function openDatabase(explicit) {
2083
- const dbPath = getDbPath(explicit);
2084
- if (dbPath !== ":memory:")
2085
- mkdirSync2(dirname2(dbPath), { recursive: true });
2086
- const db = new Database(dbPath);
2087
- db.exec("PRAGMA journal_mode = WAL");
2088
- db.exec("PRAGMA foreign_keys = ON");
2089
- return db;
2090
- }
2091
- function schemaVersion(db) {
2092
- db.exec("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)");
2093
- const row = db.query("SELECT MAX(version) AS v FROM _schema_version").get();
2094
- return row?.v ?? 0;
2095
- }
2096
- function ensureSchema(db, migrations = MIGRATIONS) {
2097
- const current = schemaVersion(db);
2098
- for (const m of migrations) {
2099
- if (m.version > current) {
2100
- db.exec(m.sql);
2101
- db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
2102
- }
2103
- }
2104
- }
2105
-
2106
- // src/api/token.ts
2107
- import { readFileSync } from "fs";
2108
- import { resolve as resolve3 } from "path";
2109
- import { homedir as homedir3 } from "os";
2110
- function defaultTokenPath() {
2111
- return process.env.OURA_TOKEN_PATH ?? resolve3(homedir3(), ".oura-token");
2112
- }
2113
- function resolveToken(explicit, tokenPath) {
2114
- if (explicit)
2115
- return { token: explicit.trim(), source: "--token" };
2116
- if (process.env.OURA_TOKEN)
2117
- return { token: process.env.OURA_TOKEN.trim(), source: "OURA_TOKEN" };
2118
- const path = tokenPath ?? defaultTokenPath();
2119
- try {
2120
- return { token: readFileSync(path, "utf-8").trim(), source: path };
2121
- } catch {
2122
- return { token: null, source: path };
2123
- }
2124
- }
2125
-
2126
- // src/api/client.ts
2127
- var BASE_URL = "https://api.ouraring.com/v2/usercollection";
2128
- var MAX_PAGES = 1e4;
2129
-
2130
- class OuraClient {
2131
- token;
2132
- constructor(options = {}) {
2133
- const { token, source } = resolveToken(options.token, options.tokenPath);
2134
- if (!token) {
2135
- throw new CliError("TOKEN_MISSING", `No Oura access token at ${source}.`, "Run `oura-cli login` or set OURA_TOKEN.");
2136
- }
2137
- this.token = token;
2138
- }
2139
- async fetch(endpoint, query) {
2140
- const rows = [];
2141
- const seenTokens = new Set;
2142
- let nextToken = null;
2143
- do {
2144
- if (seenTokens.size >= MAX_PAGES) {
2145
- throw new CliError("API_ERROR", `Oura API returned more than ${MAX_PAGES} pages for ${endpoint}; stopping.`);
2146
- }
2147
- const params = new URLSearchParams(query);
2148
- if (nextToken)
2149
- params.set("next_token", nextToken);
2150
- const page = await this.getPage(`${BASE_URL}/${endpoint}?${params}`);
2151
- for (const row of page.data)
2152
- rows.push(row);
2153
- nextToken = page.next_token;
2154
- if (nextToken && seenTokens.has(nextToken)) {
2155
- throw new CliError("API_ERROR", `Oura API repeated pagination token for ${endpoint}; stopping to avoid a loop.`);
2156
- }
2157
- if (nextToken)
2158
- seenTokens.add(nextToken);
2159
- } while (nextToken);
2160
- return rows;
2161
- }
2162
- async getPage(url) {
2163
- const response = await fetch(url, {
2164
- headers: { Authorization: `Bearer ${this.token}` }
2165
- });
2166
- if (!response.ok) {
2167
- const rawBody = await response.text();
2168
- const redacted = redactSecrets(rawBody);
2169
- const body = redacted.length > 200 ? redacted.slice(0, 200) + "\u2026 (truncated)" : redacted;
2170
- if (response.status === 401 || response.status === 403) {
2171
- throw new CliError("TOKEN_INVALID", `Oura API ${response.status}: ${body}`);
2172
- }
2173
- throw new CliError("API_ERROR", `Oura API ${response.status}: ${body}`);
2174
- }
2175
- let json;
2176
- try {
2177
- json = await response.json();
2178
- } catch {
2179
- throw new CliError("API_ERROR", "Empty response body from Oura API.");
2180
- }
2181
- const body = json;
2182
- return { data: body.data ?? [], next_token: body.next_token ?? null };
2274
+ if (sp.count > 0 && sp.avg !== null) {
2275
+ results.push({ label: "SpO2", avg: +sp.avg.toFixed(1), min: +sp.min.toFixed(1), max: +sp.max.toFixed(1), count: sp.count });
2183
2276
  }
2277
+ return results;
2184
2278
  }
2185
-
2186
- // src/lib/format-resolve.ts
2187
- function resolveFormat({ explicit, isTty }) {
2188
- if (explicit === undefined)
2189
- return isTty ? "table" : "json";
2190
- if (explicit === "table" || explicit === "json")
2191
- return explicit;
2192
- throw new CliError("BAD_ARGS", `Unknown --format value: "${explicit}". Use "table" or "json".`);
2279
+ function getStats(db, today) {
2280
+ const tables = COLLECTIONS.map((c) => {
2281
+ const row = db.query(`SELECT COUNT(*) as cnt FROM ${c.table}`).get();
2282
+ return { table: c.table, rows: row.cnt };
2283
+ });
2284
+ const range = db.query("SELECT MIN(day) as first, MAX(day) as last FROM daily_sleep").get();
2285
+ const trends = getTrends(db, 99999, today);
2286
+ const mostSteps = db.query("SELECT day, steps FROM daily_activity WHERE steps IS NOT NULL ORDER BY steps DESC LIMIT 1").get();
2287
+ const bestSleep = db.query("SELECT day, score FROM daily_sleep WHERE score IS NOT NULL ORDER BY score DESC LIMIT 1").get();
2288
+ return {
2289
+ tables,
2290
+ dateRange: range,
2291
+ trends,
2292
+ records: {
2293
+ mostSteps: mostSteps ?? null,
2294
+ bestSleep: bestSleep ?? null
2295
+ }
2296
+ };
2193
2297
  }
2194
2298
 
2195
- // src/lib/validate.ts
2196
- function assertCalendarDate(value, label) {
2197
- if (!isCalendarDate(value)) {
2198
- throw new CliError("BAD_ARGS", `${label} must be a real YYYY-MM-DD date, got "${value}".`);
2199
- }
2200
- return value;
2299
+ // src/render/format.ts
2300
+ init_source();
2301
+ function scoreColor(score) {
2302
+ if (score === null)
2303
+ return source_default.gray("\u2014");
2304
+ if (score >= 85)
2305
+ return source_default.green(String(score));
2306
+ if (score >= 70)
2307
+ return source_default.yellow(String(score));
2308
+ return source_default.red(String(score));
2201
2309
  }
2202
- function assertPositiveInt(value, label) {
2203
- const n = Number(value);
2204
- if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(n) || n < 1) {
2205
- throw new CliError("BAD_ARGS", `${label} must be a positive integer, got "${value}".`);
2206
- }
2207
- return n;
2310
+ function fmtHours(h) {
2311
+ if (h === null)
2312
+ return source_default.gray("\u2014");
2313
+ return `${h}h`;
2208
2314
  }
2209
- function assertTimezone(tz) {
2210
- try {
2211
- new Intl.DateTimeFormat("en-US", { timeZone: tz });
2212
- return tz;
2213
- } catch {
2214
- throw new CliError("BAD_ARGS", `Unknown timezone "${tz}".`, "Use an IANA name such as Europe/Berlin (env: OURA_TZ, flag: --tz).");
2315
+ function isEmptyDay(s) {
2316
+ return s.sleep_score === null && s.readiness_score === null && s.activity_score === null && s.steps === null && s.stress === null && s.spo2 === null && s.temp_deviation === null && s.sleep_hours === null && s.deep_hours === null && s.rem_hours === null && s.avg_hrv === null && s.lowest_hr === null && s.efficiency === null;
2317
+ }
2318
+ function formatDaySummary(summary, format, emptyHint) {
2319
+ if (format === "json")
2320
+ return JSON.stringify(summary, null, 2);
2321
+ if (emptyHint && isEmptyDay(summary)) {
2322
+ return [
2323
+ "",
2324
+ source_default.bold(` ${summary.day}`),
2325
+ source_default.gray("\u2500".repeat(50)),
2326
+ ` No Oura data for ${summary.day} yet.`,
2327
+ ` ${emptyHint}`
2328
+ ].join(`
2329
+ `);
2330
+ }
2331
+ const lines = [
2332
+ "",
2333
+ source_default.bold(` ${summary.day}`),
2334
+ source_default.gray("\u2500".repeat(50)),
2335
+ ` Sleep: ${scoreColor(summary.sleep_score)} Readiness: ${scoreColor(summary.readiness_score)} Activity: ${scoreColor(summary.activity_score)}`,
2336
+ ` Steps: ${summary.steps ?? source_default.gray("\u2014")}`
2337
+ ];
2338
+ if (summary.spo2 !== null)
2339
+ lines.push(` SpO2: ${summary.spo2}%`);
2340
+ if (summary.temp_deviation !== null) {
2341
+ const sign = summary.temp_deviation >= 0 ? "+" : "";
2342
+ lines.push(` Temp: ${sign}${summary.temp_deviation}\xB0C`);
2343
+ }
2344
+ if (summary.stress)
2345
+ lines.push(` Stress: ${summary.stress}`);
2346
+ if (summary.sleep_hours !== null) {
2347
+ lines.push("");
2348
+ lines.push(` Sleep: ${fmtHours(summary.sleep_hours)} total | ${fmtHours(summary.deep_hours)} deep | ${fmtHours(summary.rem_hours)} REM`);
2349
+ lines.push(` HRV: ${summary.avg_hrv ?? "\u2014"} Lowest HR: ${summary.lowest_hr ?? "\u2014"} Efficiency: ${summary.efficiency ?? "\u2014"}%`);
2215
2350
  }
2351
+ return lines.join(`
2352
+ `);
2216
2353
  }
2217
-
2218
- // src/commands/run-command.ts
2219
- var processIo = {
2220
- stdout: (s) => {
2221
- process.stdout.write(s + `
2354
+ function formatImportSummary(result) {
2355
+ const n = (table) => `${result.fetched[table] ?? 0} (+${result.added[table] ?? 0})`;
2356
+ return [
2357
+ ` Fetched ${result.startDate} \u2192 ${result.endDate}, rows fetched (+new):`,
2358
+ ` sleep ${n("daily_sleep")} readiness ${n("daily_readiness")} activity ${n("daily_activity")} sleep periods ${n("sleep_model")}`,
2359
+ ` spo2 ${n("daily_spo2")} stress ${n("daily_stress")} workouts ${n("workouts")} heart rate ${n("heartrate")} cardiovascular age ${n("cardiovascular_age")}`
2360
+ ].join(`
2222
2361
  `);
2223
- },
2224
- stderr: (s) => {
2225
- process.stderr.write(s + `
2362
+ }
2363
+ function formatWeekTable(days, format, emptyHint) {
2364
+ if (format === "json")
2365
+ return JSON.stringify(days, null, 2);
2366
+ if (emptyHint && days.length > 0 && days.every(isEmptyDay)) {
2367
+ return [
2368
+ "",
2369
+ " No Oura data for the last 7 days yet.",
2370
+ ` ${emptyHint}`
2371
+ ].join(`
2226
2372
  `);
2227
- },
2228
- exit: (code) => process.exit(code),
2229
- isTty: process.stdout.isTTY === true
2230
- };
2231
- async function execute(def, args, io = processIo) {
2232
- if (args["no-color"] || process.env.NO_COLOR)
2233
- source_default.level = 0;
2234
- let db;
2235
- let format = io.isTty ? "table" : "json";
2236
- let exitCode = 0;
2237
- try {
2238
- format = resolveFormat({ explicit: args.format, isTty: io.isTty });
2239
- const outputFormat = def.jsonOnly ? "json" : format;
2240
- const tz = assertTimezone(args.tz ?? resolveDefaultTimezone());
2241
- const ctx = { format: outputFormat, tz, today: today(tz) };
2242
- if (def.needs?.db) {
2243
- db = openDatabase(args.db);
2244
- ensureSchema(db);
2245
- ctx.db = db;
2246
- }
2247
- if (def.needs?.client) {
2248
- ctx.client = new OuraClient(args.token ? { token: args.token } : {});
2249
- }
2250
- const out = await def.run(ctx, args);
2251
- io.stdout(outputFormat === "json" ? JSON.stringify(out.json, null, 2) : out.text());
2252
- exitCode = out.exitCode ?? 0;
2253
- } catch (err) {
2254
- io.stderr(formatError(err, format).text);
2255
- exitCode = exitCodeFor(err);
2256
- } finally {
2257
- db?.close();
2258
2373
  }
2259
- if (exitCode !== 0)
2260
- io.exit(exitCode);
2374
+ const header = `${"Day".padEnd(12)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Activity".padStart(9)} ${"Steps".padStart(7)} ${"Stress".padEnd(10)}`;
2375
+ const sep = source_default.gray("\u2500".repeat(56));
2376
+ const rows = days.map((d) => `${d.day.padEnd(12)} ${scoreColor(d.sleep_score).padStart(6)} ${scoreColor(d.readiness_score).padStart(6)} ` + `${scoreColor(d.activity_score).padStart(9)} ${String(d.steps ?? "\u2014").padStart(7)} ${(d.stress ?? "\u2014").padEnd(10)}`);
2377
+ return [`
2378
+ Last 7 Days`, sep, ` ${header}`, sep, ...rows.map((r) => ` ${r}`)].join(`
2379
+ `);
2261
2380
  }
2262
- function dataCommand(def) {
2263
- return defineCommand({
2264
- meta: def.meta,
2265
- args: { ...commonArgs, ...def.args ?? {} },
2266
- run: ({ args }) => execute(def, args, processIo)
2267
- });
2381
+ function formatTrends(trends, days, format) {
2382
+ if (format === "json")
2383
+ return JSON.stringify(trends, null, 2);
2384
+ const lines = [
2385
+ "",
2386
+ source_default.bold(` Trends: last ${days} days`),
2387
+ source_default.gray("\u2500".repeat(50))
2388
+ ];
2389
+ for (const t of trends) {
2390
+ 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)`);
2391
+ }
2392
+ return lines.join(`
2393
+ `);
2394
+ }
2395
+ function formatStats(stats, format) {
2396
+ if (format === "json")
2397
+ return JSON.stringify(stats, null, 2);
2398
+ const lines = [
2399
+ "",
2400
+ source_default.bold(" Database Statistics"),
2401
+ source_default.gray("\u2550".repeat(50))
2402
+ ];
2403
+ for (const t of stats.tables) {
2404
+ lines.push(` ${t.table.padEnd(22)} ${String(t.rows).padStart(8)} rows`);
2405
+ }
2406
+ if (stats.dateRange.first) {
2407
+ lines.push(`
2408
+ Date range: ${stats.dateRange.first} \u2192 ${stats.dateRange.last}`);
2409
+ }
2410
+ for (const t of stats.trends) {
2411
+ lines.push(` ${t.label.padEnd(15)} avg: ${String(t.avg).padStart(5)} min: ${String(t.min).padStart(5)} max: ${String(t.max).padStart(5)}`);
2412
+ }
2413
+ if (stats.records.mostSteps) {
2414
+ lines.push(`
2415
+ Most steps: ${stats.records.mostSteps.steps} on ${stats.records.mostSteps.day}`);
2416
+ }
2417
+ if (stats.records.bestSleep) {
2418
+ lines.push(` Best sleep: ${stats.records.bestSleep.score} on ${stats.records.bestSleep.day}`);
2419
+ }
2420
+ return lines.join(`
2421
+ `);
2268
2422
  }
2269
2423
 
2270
2424
  // src/commands/sync.ts
2271
- async function runSync(ctx) {
2425
+ function resolveWindow(opts) {
2426
+ if (opts.to !== undefined && opts.from === undefined)
2427
+ throw new CliError("BAD_ARGS", "--to requires --from.");
2428
+ const from = opts.from === undefined ? undefined : assertCalendarDate(opts.from, "--from");
2429
+ const to = opts.to === undefined ? undefined : assertCalendarDate(opts.to, "--to");
2430
+ if (from !== undefined && to !== undefined && from > to)
2431
+ throw new CliError("BAD_ARGS", `--from (${from}) must not be after --to (${to}).`);
2432
+ return { from, to };
2433
+ }
2434
+ async function runSync(ctx, window = {}) {
2272
2435
  const lines = [];
2273
2436
  const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
2274
- const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log);
2437
+ const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window);
2275
2438
  const today = getDaySummary(ctx.db, ctx.today);
2276
2439
  return {
2277
2440
  json: { import: importResult, today },
@@ -2281,8 +2444,12 @@ async function runSync(ctx) {
2281
2444
  }
2282
2445
  var syncCommand = dataCommand({
2283
2446
  meta: { name: "sync", description: "Import latest data from Oura API and return today's summary" },
2447
+ args: {
2448
+ from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
2449
+ to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
2450
+ },
2284
2451
  needs: { db: true, client: true },
2285
- run: runSync
2452
+ run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }))
2286
2453
  });
2287
2454
 
2288
2455
  // src/commands/db.ts
@@ -2346,15 +2513,19 @@ function dayLabel(dateStr) {
2346
2513
  const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2347
2514
  return `${day} ${dd}/${mm}`;
2348
2515
  }
2349
- function getReport(db, days, today) {
2516
+ function getReport(db, days, today, tz = "UTC") {
2350
2517
  const period = days <= 7 ? "week" : "month";
2351
2518
  const weekEnd = today;
2352
2519
  const weekStart = shiftDay(today, -(days - 1));
2353
2520
  const prevWeekEnd = shiftDay(today, -days);
2354
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);
2355
2527
  const dailyRows = [];
2356
- for (let i = days - 1;i >= 0; i--) {
2357
- const d = shiftDay(today, -i);
2528
+ for (const d of windowDays) {
2358
2529
  const sl = db.query("SELECT score FROM daily_sleep WHERE day=?").get(d);
2359
2530
  const rd = db.query("SELECT score FROM daily_readiness WHERE day=?").get(d);
2360
2531
  const ac = db.query("SELECT score, steps FROM daily_activity WHERE day=?").get(d);
@@ -2364,7 +2535,8 @@ function getReport(db, days, today) {
2364
2535
  sleep: sl?.score ?? null,
2365
2536
  readiness: rd?.score ?? null,
2366
2537
  activity: ac?.score ?? null,
2367
- steps: ac?.steps ?? null
2538
+ steps: ac?.steps ?? null,
2539
+ partial: ac != null && !isComplete(d)
2368
2540
  });
2369
2541
  }
2370
2542
  const metrics = [
@@ -2375,7 +2547,8 @@ function getReport(db, days, today) {
2375
2547
  ];
2376
2548
  const averages = [];
2377
2549
  for (const [label, table, col, isSteps] of metrics) {
2378
- 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);
2379
2552
  const prev = db.query(`SELECT AVG(${col}) as avg FROM ${table} WHERE day BETWEEN ? AND ?`).get(prevWeekStart, prevWeekEnd);
2380
2553
  if (curr.cnt > 0 && curr.avg !== null) {
2381
2554
  const diff = prev.avg !== null ? curr.avg - prev.avg : null;
@@ -2384,6 +2557,7 @@ function getReport(db, days, today) {
2384
2557
  avg: curr.avg,
2385
2558
  min: curr.min,
2386
2559
  max: curr.max,
2560
+ count: curr.cnt,
2387
2561
  prevAvg: prev.avg,
2388
2562
  diff,
2389
2563
  isSteps
@@ -2394,7 +2568,7 @@ function getReport(db, days, today) {
2394
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;
2395
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) }));
2396
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) }));
2397
- 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) }));
2398
2572
  const sd = db.query(`SELECT AVG(total_sleep_duration) as totalSleep, AVG(deep_sleep_duration) as deepSleep,
2399
2573
  AVG(rem_sleep_duration) as remSleep, AVG(light_sleep_duration) as lightSleep,
2400
2574
  AVG(efficiency) as efficiency, AVG(average_hrv) as hrv, AVG(lowest_heart_rate) as lowestHr
@@ -2403,7 +2577,7 @@ function getReport(db, days, today) {
2403
2577
  const recommendations = [];
2404
2578
  const avgSleep = db.query("SELECT AVG(score) as avg FROM daily_sleep WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2405
2579
  const avgReady = db.query("SELECT AVG(score) as avg FROM daily_readiness WHERE day BETWEEN ? AND ?").get(weekStart, weekEnd);
2406
- 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);
2407
2581
  if (avgSleep.avg !== null && avgSleep.avg < 75) {
2408
2582
  recommendations.push("sleep_low");
2409
2583
  } else if (avgSleep.avg !== null && avgSleep.avg >= 85) {
@@ -2419,7 +2593,7 @@ function getReport(db, days, today) {
2419
2593
  } else if (avgSteps.avg !== null && avgSteps.avg >= 1e4) {
2420
2594
  recommendations.push("steps_great");
2421
2595
  }
2422
- 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 };
2423
2597
  }
2424
2598
 
2425
2599
  // src/render/format-report.ts
@@ -2475,12 +2649,24 @@ function bucketDaysIntoWeeks(days) {
2475
2649
  avgSleep: sleepVals.length > 0 ? sleepVals.reduce((a, b) => a + b, 0) / sleepVals.length : null,
2476
2650
  avgReadiness: readinessVals.length > 0 ? readinessVals.reduce((a, b) => a + b, 0) / readinessVals.length : null,
2477
2651
  avgActivity: activityVals.length > 0 ? activityVals.reduce((a, b) => a + b, 0) / activityVals.length : null,
2478
- 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)
2479
2654
  });
2480
2655
  }
2481
2656
  return buckets;
2482
2657
  }
2483
- 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") {
2484
2670
  if (format === "json")
2485
2671
  return JSON.stringify(data, null, 2);
2486
2672
  const lines = [];
@@ -2491,6 +2677,9 @@ function formatReport(data, format, period) {
2491
2677
  lines.push(source_default.bold(" Oura Monthly Report"));
2492
2678
  }
2493
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));
2494
2683
  lines.push("");
2495
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;
2496
2685
  if (!hasReportData) {
@@ -2506,7 +2695,7 @@ function formatReport(data, format, period) {
2506
2695
  lines.push(` ${"Day".padEnd(10)} ${"Sleep".padStart(6)} ${"Ready".padStart(6)} ${"Active".padStart(7)} ${"Steps".padStart(8)}`);
2507
2696
  lines.push(source_default.gray(" " + "\u2500".repeat(52)));
2508
2697
  for (const d of data.days) {
2509
- 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)}`);
2510
2699
  }
2511
2700
  lines.push("");
2512
2701
  } else {
@@ -2519,7 +2708,7 @@ function formatReport(data, format, period) {
2519
2708
  const avgSleepInt = b.avgSleep !== null ? Math.round(b.avgSleep) : null;
2520
2709
  const avgReadyInt = b.avgReadiness !== null ? Math.round(b.avgReadiness) : null;
2521
2710
  const avgActiveInt = b.avgActivity !== null ? Math.round(b.avgActivity) : null;
2522
- 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)}`);
2523
2712
  }
2524
2713
  lines.push("");
2525
2714
  }
@@ -2580,17 +2769,18 @@ var reportCommand = dataCommand({
2580
2769
  if (period !== "week" && period !== "month") {
2581
2770
  throw new CliError("BAD_ARGS", `--period must be "week" or "month", got "${period}".`);
2582
2771
  }
2583
- const data = getReport(ctx.db, period === "week" ? 7 : 30, ctx.today);
2584
- 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) };
2585
2774
  }
2586
2775
  });
2587
2776
 
2588
2777
  // src/commands/healthcheck.ts
2589
2778
  function healthcheckCommand(version) {
2590
2779
  return defineCommand({
2591
- 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)." },
2592
2781
  args: { ...commonArgs },
2593
2782
  run({ args }) {
2783
+ assertKnownArgs(commonArgs, args);
2594
2784
  const start = Date.now();
2595
2785
  let ok = true;
2596
2786
  let error;
@@ -2615,6 +2805,8 @@ function statusSymbol(status) {
2615
2805
  return source_default.green("\u2713");
2616
2806
  if (status === "warn")
2617
2807
  return source_default.yellow("!");
2808
+ if (status === "skip")
2809
+ return source_default.gray("\u2013");
2618
2810
  return source_default.red("\u2717");
2619
2811
  }
2620
2812
  function formatDoctorTable(result) {
@@ -2641,7 +2833,7 @@ async function runChecks(deps) {
2641
2833
  if (!token) {
2642
2834
  checks.push({ id: "token-valid", status: "fail", detail: "No token to validate.", fix: "oura-cli login" });
2643
2835
  } else if (deps.offline) {
2644
- checks.push({ id: "token-valid", status: "ok", detail: "Skipped (--offline)." });
2836
+ checks.push({ id: "token-valid", status: "skip", detail: "Not checked (--offline)." });
2645
2837
  } else {
2646
2838
  try {
2647
2839
  const client = deps.createClient(token);
@@ -2681,8 +2873,9 @@ async function runChecks(deps) {
2681
2873
  checks.push({ id: "data", status: "fail", detail: "Cannot check data \u2014 database unavailable." });
2682
2874
  }
2683
2875
  db?.close();
2684
- const ok = checks.every((c) => c.status === "ok");
2685
- const nextStep = checks.find((c) => c.status !== "ok")?.fix ?? null;
2876
+ const settled = (s) => s === "ok" || s === "skip";
2877
+ const ok = checks.every((c) => settled(c.status));
2878
+ const nextStep = checks.find((c) => !settled(c.status))?.fix ?? null;
2686
2879
  return { ok, checks, nextStep };
2687
2880
  }
2688
2881
  var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
@@ -2748,14 +2941,15 @@ function buildOpenclawManifest(version, commands) {
2748
2941
  examples: EXAMPLES[c.name] ?? [`oura-cli ${c.name}`]
2749
2942
  })),
2750
2943
  envVars: ["OURA_TOKEN", "OURA_TOKEN_PATH", "OURA_DB_PATH", "OURA_TZ"],
2751
- 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" } }
2752
2945
  };
2753
2946
  }
2754
2947
  function manifestCommand(version, getCommands) {
2755
2948
  return defineCommand({
2756
2949
  meta: { name: "manifest", description: "Print openclaw-tool-registry-compatible manifest as JSON." },
2757
- args: {},
2758
- run() {
2950
+ args: { ...commonArgs },
2951
+ run({ args }) {
2952
+ assertKnownArgs(commonArgs, args);
2759
2953
  console.log(JSON.stringify(buildOpenclawManifest(version, getCommands()), null, 2));
2760
2954
  }
2761
2955
  });
@@ -2812,53 +3006,26 @@ var fetchCommand = dataCommand({
2812
3006
  }
2813
3007
  });
2814
3008
 
2815
- // src/lib/argv-normalize.ts
2816
- var GLOBAL_FLAGS_WITH_VALUE = new Set(["--format", "--token", "--db", "--tz"]);
2817
- var GLOBAL_FLAGS_BOOLEAN = new Set(["--no-color"]);
2818
- var SUBCOMMANDS = new Set([
2819
- "login",
2820
- "describe",
2821
- "healthcheck",
2822
- "doctor",
2823
- "manifest",
2824
- "fetch",
2825
- "sync",
2826
- "db",
2827
- "report"
2828
- ]);
2829
- function normalizeArgv(argv) {
2830
- const [bun, script, ...rest] = argv;
2831
- const subIdx = rest.findIndex((a) => SUBCOMMANDS.has(a));
2832
- if (subIdx < 0)
2833
- return argv;
2834
- const before = rest.slice(0, subIdx);
2835
- const subcommandOnwards = rest.slice(subIdx);
2836
- const hoisted = [];
2837
- const leftover = [];
2838
- for (let i = 0;i < before.length; i++) {
2839
- const tok = before[i];
2840
- if (tok.includes("=")) {
2841
- const name = tok.slice(0, tok.indexOf("="));
2842
- if (GLOBAL_FLAGS_WITH_VALUE.has(name) || GLOBAL_FLAGS_BOOLEAN.has(name)) {
2843
- hoisted.push(tok);
2844
- continue;
2845
- }
2846
- }
2847
- if (GLOBAL_FLAGS_WITH_VALUE.has(tok)) {
2848
- hoisted.push(tok);
2849
- if (i + 1 < before.length) {
2850
- hoisted.push(before[i + 1]);
2851
- i++;
2852
- }
2853
- continue;
2854
- }
2855
- if (GLOBAL_FLAGS_BOOLEAN.has(tok)) {
2856
- hoisted.push(tok);
2857
- continue;
3009
+ // src/lib/citty-error.ts
3010
+ var ANSI = /\u001b\[[0-9;]*m/g;
3011
+ function fromCittyError(err, removedCommandHints = {}) {
3012
+ const code = err?.code;
3013
+ if (typeof code !== "string")
3014
+ return err;
3015
+ const message = (err instanceof Error ? err.message : String(err)).replace(ANSI, "");
3016
+ switch (code) {
3017
+ case "E_UNKNOWN_COMMAND": {
3018
+ const name = message.replace(/^Unknown command\s*/, "").trim();
3019
+ const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
3020
+ return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
2858
3021
  }
2859
- leftover.push(tok);
3022
+ case "EARG":
3023
+ return new CliError("BAD_ARGS", message.endsWith(".") ? message : `${message}.`, "Run the command with --help to see its arguments.");
3024
+ case "E_NO_COMMAND":
3025
+ return new CliError("BAD_ARGS", "No command specified.", "Run `oura-cli --help` for the list of commands.");
3026
+ default:
3027
+ return err;
2860
3028
  }
2861
- return [bun, script, ...leftover, ...subcommandOnwards, ...hoisted];
2862
3029
  }
2863
3030
 
2864
3031
  // src/index.ts
@@ -2866,7 +3033,7 @@ var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.ur
2866
3033
  if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
2867
3034
  source_default.level = 0;
2868
3035
  }
2869
- var subCommands = {
3036
+ var subCommands = Object.assign(Object.create(null), {
2870
3037
  login: loginCommand,
2871
3038
  describe: describeCommand(VERSION, () => subCommands),
2872
3039
  healthcheck: healthcheckCommand(VERSION),
@@ -2876,6 +3043,18 @@ var subCommands = {
2876
3043
  sync: syncCommand,
2877
3044
  db: dbCommand,
2878
3045
  report: reportCommand
3046
+ });
3047
+ 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`.";
3048
+ var REMOVED_COMMANDS = {
3049
+ 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.",
3050
+ import: "`db import` was removed in 0.5.0; `oura-cli sync` downloads and stores everything.",
3051
+ sleep: FETCH_HINT,
3052
+ readiness: FETCH_HINT,
3053
+ activity: FETCH_HINT,
3054
+ hr: FETCH_HINT,
3055
+ spo2: FETCH_HINT,
3056
+ stress: FETCH_HINT,
3057
+ workout: FETCH_HINT
2879
3058
  };
2880
3059
  var main = defineCommand({
2881
3060
  meta: {
@@ -2886,5 +3065,16 @@ var main = defineCommand({
2886
3065
  args: { ...commonArgs },
2887
3066
  subCommands
2888
3067
  });
2889
- var normalized = normalizeArgv(process.argv);
2890
- runMain(main, { rawArgs: normalized.slice(2) });
3068
+ var rawArgs = normalizeArgv(process.argv).slice(2);
3069
+ var wantsHelp = rawArgs.some((a) => a === "--help" || a === "-h") || rawArgs.length === 0 && process.stdout.isTTY === true;
3070
+ if (isVersionRequest(rawArgs)) {
3071
+ console.log(VERSION);
3072
+ } else if (wantsHelp) {
3073
+ runMain(main, { rawArgs });
3074
+ } else {
3075
+ runCommand(main, { rawArgs }).catch((raw) => {
3076
+ const err = fromCittyError(raw, REMOVED_COMMANDS);
3077
+ emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
3078
+ process.exit(exitCodeFor(err));
3079
+ });
3080
+ }