@drakulavich/oura-cli 0.5.0 → 0.5.1

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}`);
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,71 +1621,253 @@ 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 },
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 },
1392
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 },
@@ -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 = [];
@@ -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
@@ -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
@@ -2591,6 +2758,7 @@ function healthcheckCommand(version) {
2591
2758
  meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs})." },
2592
2759
  args: { ...commonArgs },
2593
2760
  run({ args }) {
2761
+ assertKnownArgs(commonArgs, args);
2594
2762
  const start = Date.now();
2595
2763
  let ok = true;
2596
2764
  let error;
@@ -2615,6 +2783,8 @@ function statusSymbol(status) {
2615
2783
  return source_default.green("\u2713");
2616
2784
  if (status === "warn")
2617
2785
  return source_default.yellow("!");
2786
+ if (status === "skip")
2787
+ return source_default.gray("\u2013");
2618
2788
  return source_default.red("\u2717");
2619
2789
  }
2620
2790
  function formatDoctorTable(result) {
@@ -2641,7 +2811,7 @@ async function runChecks(deps) {
2641
2811
  if (!token) {
2642
2812
  checks.push({ id: "token-valid", status: "fail", detail: "No token to validate.", fix: "oura-cli login" });
2643
2813
  } else if (deps.offline) {
2644
- checks.push({ id: "token-valid", status: "ok", detail: "Skipped (--offline)." });
2814
+ checks.push({ id: "token-valid", status: "skip", detail: "Not checked (--offline)." });
2645
2815
  } else {
2646
2816
  try {
2647
2817
  const client = deps.createClient(token);
@@ -2681,8 +2851,9 @@ async function runChecks(deps) {
2681
2851
  checks.push({ id: "data", status: "fail", detail: "Cannot check data \u2014 database unavailable." });
2682
2852
  }
2683
2853
  db?.close();
2684
- const ok = checks.every((c) => c.status === "ok");
2685
- const nextStep = checks.find((c) => c.status !== "ok")?.fix ?? null;
2854
+ const settled = (s) => s === "ok" || s === "skip";
2855
+ const ok = checks.every((c) => settled(c.status));
2856
+ const nextStep = checks.find((c) => !settled(c.status))?.fix ?? null;
2686
2857
  return { ok, checks, nextStep };
2687
2858
  }
2688
2859
  var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
@@ -2754,8 +2925,9 @@ function buildOpenclawManifest(version, commands) {
2754
2925
  function manifestCommand(version, getCommands) {
2755
2926
  return defineCommand({
2756
2927
  meta: { name: "manifest", description: "Print openclaw-tool-registry-compatible manifest as JSON." },
2757
- args: {},
2758
- run() {
2928
+ args: { ...commonArgs },
2929
+ run({ args }) {
2930
+ assertKnownArgs(commonArgs, args);
2759
2931
  console.log(JSON.stringify(buildOpenclawManifest(version, getCommands()), null, 2));
2760
2932
  }
2761
2933
  });
@@ -2812,53 +2984,26 @@ var fetchCommand = dataCommand({
2812
2984
  }
2813
2985
  });
2814
2986
 
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;
2987
+ // src/lib/citty-error.ts
2988
+ var ANSI = /\u001b\[[0-9;]*m/g;
2989
+ function fromCittyError(err, removedCommandHints = {}) {
2990
+ const code = err?.code;
2991
+ if (typeof code !== "string")
2992
+ return err;
2993
+ const message = (err instanceof Error ? err.message : String(err)).replace(ANSI, "");
2994
+ switch (code) {
2995
+ case "E_UNKNOWN_COMMAND": {
2996
+ const name = message.replace(/^Unknown command\s*/, "").trim();
2997
+ const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
2998
+ return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
2858
2999
  }
2859
- leftover.push(tok);
3000
+ case "EARG":
3001
+ return new CliError("BAD_ARGS", message.endsWith(".") ? message : `${message}.`, "Run the command with --help to see its arguments.");
3002
+ case "E_NO_COMMAND":
3003
+ return new CliError("BAD_ARGS", "No command specified.", "Run `oura-cli --help` for the list of commands.");
3004
+ default:
3005
+ return err;
2860
3006
  }
2861
- return [bun, script, ...leftover, ...subcommandOnwards, ...hoisted];
2862
3007
  }
2863
3008
 
2864
3009
  // src/index.ts
@@ -2866,7 +3011,7 @@ var VERSION = JSON.parse(readFileSync2(new URL("../package.json", import.meta.ur
2866
3011
  if (process.argv.includes("--no-color") || process.env.NO_COLOR) {
2867
3012
  source_default.level = 0;
2868
3013
  }
2869
- var subCommands = {
3014
+ var subCommands = Object.assign(Object.create(null), {
2870
3015
  login: loginCommand,
2871
3016
  describe: describeCommand(VERSION, () => subCommands),
2872
3017
  healthcheck: healthcheckCommand(VERSION),
@@ -2876,6 +3021,18 @@ var subCommands = {
2876
3021
  sync: syncCommand,
2877
3022
  db: dbCommand,
2878
3023
  report: reportCommand
3024
+ });
3025
+ 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`.";
3026
+ var REMOVED_COMMANDS = {
3027
+ 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.",
3028
+ import: "`db import` was removed in 0.5.0; `oura-cli sync` downloads and stores everything.",
3029
+ sleep: FETCH_HINT,
3030
+ readiness: FETCH_HINT,
3031
+ activity: FETCH_HINT,
3032
+ hr: FETCH_HINT,
3033
+ spo2: FETCH_HINT,
3034
+ stress: FETCH_HINT,
3035
+ workout: FETCH_HINT
2879
3036
  };
2880
3037
  var main = defineCommand({
2881
3038
  meta: {
@@ -2886,5 +3043,16 @@ var main = defineCommand({
2886
3043
  args: { ...commonArgs },
2887
3044
  subCommands
2888
3045
  });
2889
- var normalized = normalizeArgv(process.argv);
2890
- runMain(main, { rawArgs: normalized.slice(2) });
3046
+ var rawArgs = normalizeArgv(process.argv).slice(2);
3047
+ var wantsHelp = rawArgs.some((a) => a === "--help" || a === "-h") || rawArgs.length === 0 && process.stdout.isTTY === true;
3048
+ if (isVersionRequest(rawArgs)) {
3049
+ console.log(VERSION);
3050
+ } else if (wantsHelp) {
3051
+ runMain(main, { rawArgs });
3052
+ } else {
3053
+ runCommand(main, { rawArgs }).catch((raw) => {
3054
+ const err = fromCittyError(raw, REMOVED_COMMANDS);
3055
+ emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
3056
+ process.exit(exitCodeFor(err));
3057
+ });
3058
+ }