Skill: pb3975/fitbit-health (version 0.1.1)

--- name: fitbit description: Query Fitbit health data (activity, sleep, heart rate, weight) via CLI. Use when answering health/fitness questions that require Fitbit data, or when the user asks about their steps, sleep, heart rate, or weight from Fitbit. metadata: {"clawdbot":{"emoji":"💪","requires":{"bins":["fitbit"]}}} --- # Fitbit CLI Retrieve health and fitness data from Fitbit's Web API. ## Setup 1. Register an app at https://dev.fitbit.com/apps - OAuth 2.0 Application Type: **Personal** - Callback URL: `http://localhost:18787/callback` 2. Run `fitbit configure` and enter your Client ID 3. Run `fitbit login` to authorize ## Quick Reference ```bash # Setup & auth fitbit configure # Set client ID (first time) fitbit login # Authorize via browser fitbit logout # Sign out fitbit status # Check auth status # Data fitbit profile # User profile info fitbit activity [date] # Daily activity summary fitbit activity steps [date] # Just steps fitbit summary [date] # Full daily summary fitbit today # Today's summary (shortcut) ``` ## Options All commands support: - `--json` — JSON output - `--no-color` — Plain text output - `--verbose` — Debug/HTTP details - `--tz <zone>` — Override timezone (e.g., `America/Chicago`) ## Examples ```bash # Get today's step count fitbit activity steps # Get yesterday's full summary as JSON fitbit summary 2026-01-25 --json # Check if authenticated fitbit status ``` ## Notes - Dates default to today if omitted - Date format: `YYYY-MM-DD` or `today` - Tokens are stored in `~/.config/fitbit-cli/tokens.json` (chmod 600) - Token refresh is automatic

path: README.md
content: # 💪 Fitbit Skill for Clawdbot

A [Clawdbot](https://github.com/clawdbot/clawdbot) skill that provides CLI access to Fitbit health data. Enables your AI agent to answer questions about your activity, heart rate, and fitness metrics.

## What is Clawdbot?

[Clawdbot](https://github.com/clawdbot/clawdbot) is an AI agent platform that connects Claude to your tools, services, and data. Skills extend Clawdbot's capabilities — this one adds Fitbit integration.

## Features

- 🔐 Secure OAuth 2.0 PKCE authentication
- 📊 Activity summaries (steps, calories, distance, floors)
- 💓 Heart rate and resting HR data
- 🏃 Active minutes breakdown
- 📅 Historical data by date
- 🤖 Natural language queries through Clawdbot

## Installation

### As a Clawdbot Skill

```bash
# Clone to your Clawdbot workspace skills folder
cd ~/clawd/skills
git clone https://github.com/pb3975/clawdbot-fitbit-skill.git fitbit

# Install and build
cd fitbit
npm install
npm run build

# Link globally so Clawdbot can find the CLI
npm link
```

Clawdbot will automatically detect the skill via `SKILL.md`.

### Standalone CLI

```bash
npm install -g clawdbot-fitbit-skill
```

## Setup

1. **Create a Fitbit App**
   - Go to [dev.fitbit.com/apps](https://dev.fitbit.com/apps)
   - Register a new app with:
     - **OAuth 2.0 Application Type:** Personal
     - **Callback URL:** `http://localhost:18787/callback`

2. **Configure**
   ```bash
   fitbit configure
   ```
   Enter your Client ID when prompted.

3. **Authenticate**
   ```bash
   fitbit login
   ```
   Your browser will open for Fitbit authorization.

## Usage

### Through Clawdbot (Natural Language)

Once installed, just ask Clawdbot:

- *"How many steps did I take today?"*
- *"What's my resting heart rate?"*
- *"Show me my activity summary for yesterday"*
- *"How active was I this week?"*

### Direct CLI

```bash
# Today's summary
fitbit today

# Activity details
fitbit activity
fitbit activity 2024-01-15

# Specific metrics
fitbit activity steps
fitbit activity calories

# User profile
fitbit profile

# Auth status
fitbit status

# Sign out
fitbit logout
```

### Options

All commands support:
- `--json` — Output as JSON (useful for scripting)
- `--no-color` — Plain text output
- `--verbose` — Debug info
- `--tz <zone>` — Override timezone

## Security

- Tokens stored in `~/.config/fitbit-cli/tokens.json` with 0600 permissions
- OAuth uses PKCE (no client secret stored)
- Callback server binds to 127.0.0.1 only
- Temp auth files use secure random paths

## Related

- [Clawdbot](https://github.com/clawdbot/clawdbot) - AI agent platform
- [ClawdHub](https://clawdhub.com) - Skill marketplace

## License

MIT


path: package.json
content: {
  "name": "clawdbot-fitbit-skill",
  "version": "0.1.1",
  "description": "Clawdbot skill for accessing Fitbit health data (activity, heart rate, steps)",
  "type": "module",
  "bin": {
    "fitbit": "dist/cli.js"
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "dev": "tsx src/cli.ts",
    "lint": "tsc -p tsconfig.json --noEmit",
    "prepublishOnly": "npm run build"
  },
  "keywords": [
    "clawdbot",
    "clawdbot-skill",
    "fitbit",
    "health",
    "fitness",
    "cli",
    "ai-agent",
    "activity",
    "steps",
    "heart-rate"
  ],
  "author": "William Metz",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/pb3975/clawdbot-fitbit-skill.git"
  },
  "homepage": "https://github.com/pb3975/clawdbot-fitbit-skill#readme",
  "bugs": {
    "url": "https://github.com/pb3975/clawdbot-fitbit-skill/issues"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "files": [
    "dist",
    "SKILL.md"
  ],
  "dependencies": {
    "chalk": "^5.3.0",
    "cli-table3": "^0.6.5",
    "commander": "^12.0.0",
    "date-fns": "^3.6.0",
    "open": "^10.0.4"
  },
  "devDependencies": {
    "@types/node": "^20.11.30",
    "esbuild": "^0.25.0",
    "tsx": "^4.7.1",
    "typescript": "^5.3.3"
  }
}


path: src/cli.ts
content: #!/usr/bin/env node
import { Command } from "commander";
import { registerAllCommands } from "./commands/index.js";

const program = new Command();

program
  .name("fitbit")
  .description("Fitbit CLI - Access your Fitbit health data")
  .version("0.1.0");

registerAllCommands(program);

program.parseAsync(process.argv).catch((error: Error) => {
  console.error(`Error: ${error.message}`);
  process.exit(1);
});


path: src/client/api.ts
content: import { requestJson } from "./http.js";
import { readConfig } from "../storage/config.js";
import { readTokens, writeTokens, clearTokens } from "../storage/tokens.js";
import { refreshTokens } from "./auth.js";
import { FitbitActivityResponse, FitbitProfileResponse } from "../types/fitbit.js";
import { ConfigData, TokenData } from "../types/config.js";

const API_BASE = "https://api.fitbit.com/1";

export class FitbitClient {
  constructor(
    private config: ConfigData,
    private tokens: TokenData,
    private verbose = false,
  ) {}

  static async load(verbose = false): Promise<FitbitClient> {
    const config = await readConfig();
    if (!config) {
      throw new Error("Missing client configuration. Run `fitbit configure` first.");
    }
    const tokens = await readTokens();
    if (!tokens) {
      throw new Error("Not authenticated. Run `fitbit login`.");
    }
    return new FitbitClient(config, tokens, verbose);
  }

  getTokenInfo(): TokenData {
    return this.tokens;
  }

  async getProfile(): Promise<FitbitProfileResponse> {
    return this.requestWithAuth<FitbitProfileResponse>(`${API_BASE}/user/-/profile.json`);
  }

  async getActivity(date: string): Promise<FitbitActivityResponse> {
    return this.requestWithAuth<FitbitActivityResponse>(
      `${API_BASE}/user/-/activities/date/${date}.json`,
    );
  }

  async refreshIfNeeded(): Promise<void> {
    const timeRemaining = this.tokens.expiresAt - Date.now();
    if (timeRemaining > 5 * 60 * 1000) {
      return;
    }
    await this.performRefresh();
  }

  private async requestWithAuth<T>(url: string): Promise<T> {
    await this.refreshIfNeeded();
    try {
      const { data } = await requestJson<T>(url, {
        headers: {
          Authorization: `Bearer ${this.tokens.accessToken}`,
        },
        verbose: this.verbose,
      });
      return data;
    } catch (error) {
      const status = (error as any).status as number | undefined;
      if (status === 401) {
        await this.performRefresh();
        const { data } = await requestJson<T>(url, {
          headers: {
            Authorization: `Bearer ${this.tokens.accessToken}`,
          },
          verbose: this.verbose,
        });
        return data;
      }
      throw error;
    }
  }

  private async performRefresh(): Promise<void> {
    try {
      const newTokens = await refreshTokens(
        this.tokens.refreshToken,
        this.config.clientId,
        this.verbose,
      );
      this.tokens = newTokens;
      await writeTokens(newTokens);
    } catch (error) {
      const status = (error as any).status as number | undefined;
      const data = (error as any).data as { errors?: Array<{ errorType: string }> } | undefined;
      if (status === 400 || status === 401) {
        const errorType = data?.errors?.[0]?.errorType;
        if (errorType === "invalid_grant") {
          await clearTokens();
          throw new Error("Session expired. Run `fitbit login` to re-authenticate.");
        }
      }
      throw error;
    }
  }
}


path: src/client/auth.ts
content: import crypto from "node:crypto";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { promises as fs } from "node:fs";
import open from "open";
import { requestJson } from "./http.js";
import { ConfigData, OAuthStateData, TokenData } from "../types/config.js";
import { FitbitTokenResponse, FitbitErrorResponse } from "../types/fitbit.js";

const TOKEN_URL = "https://api.fitbit.com/oauth2/token";
const REVOKE_URL = "https://api.fitbit.com/oauth2/revoke";
const REDIRECT_PATH = "/callback";
const DEFAULT_SCOPE = "activity heartrate sleep weight profile";
const AUTH_BASE = "https://www.fitbit.com/oauth2/authorize";

export interface OAuthResult {
  tokens: TokenData;
}

export async function startOAuthFlow(config: ConfigData, verbose = false): Promise<OAuthResult> {
  const codeVerifier = createCodeVerifier();
  const codeChallenge = createCodeChallenge(codeVerifier);
  const state = crypto.randomBytes(16).toString("hex");
  const tempFile = getTempAuthPath();

  await fs.writeFile(
    tempFile,
    JSON.stringify({ codeVerifier, state, createdAt: Date.now() } satisfies OAuthStateData),
    { encoding: "utf8", mode: 0o600, flag: "wx" },
  );

  const { port, server, waitForCallback } = await startCallbackServer(
    config.callbackPort ?? 18787,
  );

  const redirectUri = `http://127.0.0.1:${port}${REDIRECT_PATH}`;
  const authUrl = new URL(AUTH_BASE);
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("client_id", config.clientId);
  authUrl.searchParams.set("redirect_uri", redirectUri);
  authUrl.searchParams.set("scope", DEFAULT_SCOPE);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
  authUrl.searchParams.set("state", state);

  if (verbose) {
    console.error(`[fitbit] OAuth redirect: ${redirectUri}`);
  }

  await open(authUrl.toString());

  try {
    const { code, returnedState } = await waitForCallback();
    const storedState = await readTempAuth(tempFile);
    if (!storedState || storedState.state !== returnedState) {
      throw new Error("OAuth state mismatch. Please retry login.");
    }

    const tokens = await exchangeCode({
      code,
      codeVerifier: storedState.codeVerifier,
      clientId: config.clientId,
      redirectUri,
      verbose,
    });

    return { tokens };
  } finally {
    server.close();
    await safeUnlink(tempFile);
  }
}

export async function refreshTokens(
  refreshToken: string,
  clientId: string,
  verbose = false,
): Promise<TokenData> {
  const body = new URLSearchParams();
  body.set("grant_type", "refresh_token");
  body.set("refresh_token", refreshToken);
  body.set("client_id", clientId);

  const { data } = await requestJson<FitbitTokenResponse>(TOKEN_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: body.toString(),
    verbose,
  });

  return mapTokenResponse(data);
}

export async function revokeToken([REDACTED_SECRET], clientId: string, verbose = false): Promise<void> {
  const body = new URLSearchParams();
  body.set("token", token);
  body.set("client_id", clientId);

  await requestJson<FitbitErrorResponse>(REVOKE_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: body.toString(),
    verbose,
  }).catch(() => undefined);
}

async function exchangeCode(params: {
  code: string;
  codeVerifier: string;
  clientId: string;
  redirectUri: string;
  verbose: boolean;
}): Promise<TokenData> {
  const body = new URLSearchParams();
  body.set("grant_type", "authorization_code");
  body.set("code", params.code);
  body.set("code_verifier", params.codeVerifier);
  body.set("redirect_uri", params.redirectUri);
  body.set("client_id", params.clientId);

  const { data } = await requestJson<FitbitTokenResponse>(TOKEN_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: body.toString(),
    verbose: params.verbose,
  });

  return mapTokenResponse(data);
}

function mapTokenResponse(response: FitbitTokenResponse): TokenData {
  return {
    accessToken: response.access_token,
    refreshToken: response.refresh_token,
    expiresAt: Date.now() + response.expires_in * 1000,
    userId: response.user_id,
    scopes: response.scope.split(" "),
  };
}

function createCodeVerifier(): string {
  return base64Url(crypto.randomBytes(64));
}

function createCodeChallenge(verifier: string): string {
  return base64Url(crypto.createHash("sha256").update(verifier).digest());
}

function base64Url(buffer: Buffer): string {
  return buffer
    .toString("base64")
    .replace(/=/g, "")
    .replace(/\+/g, "-")
    .replace(/\//g, "_");
}

function getTempAuthPath(): string {
  const randomId = crypto.randomBytes(16).toString("hex");
  return path.join(os.tmpdir(), `fitbit-auth-${process.pid}-${randomId}.json`);
}

async function readTempAuth(pathname: string): Promise<OAuthStateData | null> {
  try {
    const raw = await fs.readFile(pathname, "utf8");
    return JSON.parse(raw) as OAuthStateData;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return null;
    }
    throw error;
  }
}

async function safeUnlink(pathname: string): Promise<void> {
  try {
    await fs.unlink(pathname);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return;
    }
  }
}

async function startCallbackServer(startPort: number): Promise<{
  port: number;
  server: http.Server;
  waitForCallback: () => Promise<{ code: string; returnedState: string }>;
}> {
  const ports = [startPort, startPort + 1, startPort + 2, startPort + 3];

  let server: http.Server | null = null;
  let port = startPort;
  for (const candidate of ports) {
    try {
      server = await new Promise<http.Server>((resolve, reject) => {
        const srv = http.createServer();
        srv.once("error", reject);
        srv.listen(candidate, "127.0.0.1", () => resolve(srv));
      });
      port = candidate;
      break;
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === "EADDRINUSE") {
        continue;
      }
      throw error;
    }
  }

  if (!server) {
    throw new Error("Unable to start local OAuth callback server.");
  }

  const waitForCallback = () =>
    new Promise<{ code: string; returnedState: string }>((resolve, reject) => {
      const timeout = setTimeout(() => {
        reject(new Error("OAuth flow timed out. Please retry login."));
        server?.close();
      }, 5 * 60 * 1000);

      server?.on("request", (req, res) => {
        if (!req.url) {
          res.writeHead(400);
          res.end("Missing callback URL.");
          return;
        }
        const url = new URL(req.url, `http://127.0.0.1:${port}`);
        if (url.pathname !== REDIRECT_PATH) {
          res.writeHead(404);
          res.end("Not found.");
          return;
        }
        const code = url.searchParams.get("code");
        const returnedState = url.searchParams.get("state");
        if (!code || !returnedState) {
          res.writeHead(400);
          res.end("Missing OAuth code.");
          return;
        }
        clearTimeout(timeout);
        res.writeHead(200, { "Content-Type": "text/plain" });
        res.end("Fitbit authorization complete. You can close this window.");
        resolve({ code, returnedState });
      });
    });

  return { port, server, waitForCallback };
}


path: src/client/http.ts
content: import { setTimeout as delay } from "node:timers/promises";

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_RETRIES = 3;

export interface HttpRequestOptions {
  method?: string;
  headers?: Record<string, string>;
  body?: string;
  timeoutMs?: number;
  retries?: number;
  signal?: AbortSignal;
  verbose?: boolean;
}

export async function requestJson<T>(
  url: string,
  options: HttpRequestOptions = {},
): Promise<{ data: T; response: Response }> {
  const response = await fetchWithRetry(url, options);
  const text = await response.text();
  let data = {} as T;
  if (text) {
    try {
      data = JSON.parse(text) as T;
    } catch (error) {
      const parseError = new Error(`Failed to parse JSON response from ${url}.`);
      (parseError as any).cause = error;
      (parseError as any).status = response.status;
      throw parseError;
    }
  }
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status} ${response.statusText}`);
    (error as any).status = response.status;
    (error as any).data = data;
    throw error;
  }
  return { data, response };
}

export async function fetchWithRetry(
  url: string,
  options: HttpRequestOptions,
): Promise<Response> {
  const retries = options.retries ?? DEFAULT_RETRIES;
  let attempt = 0;
  while (true) {
    attempt += 1;
    try {
      const response = await fetchWithTimeout(url, options);
      if (response.status === 429 && attempt < retries) {
        const retryAfter = response.headers.get("retry-after");
        const delayMs = retryAfter ? Number(retryAfter) * 1000 : 1000 * attempt;
        await delay(delayMs);
        continue;
      }
      return response;
    } catch (error) {
      if (attempt >= retries) {
        throw error;
      }
      await delay(1000 * attempt);
    }
  }
}

async function fetchWithTimeout(
  url: string,
  options: HttpRequestOptions,
): Promise<Response> {
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  const mergedSignal = options.signal
    ? mergeSignals(options.signal, controller.signal)
    : controller.signal;

  try {
    if (options.verbose) {
      // eslint-disable-next-line no-console
      console.error(`[fitbit] ${options.method ?? "GET"} ${url}`);
    }
    return await fetch(url, {
      method: options.method ?? "GET",
      headers: options.headers,
      body: options.body,
      signal: mergedSignal,
    });
  } finally {
    clearTimeout(timer);
  }
}

function mergeSignals(signalA: AbortSignal, signalB: AbortSignal): AbortSignal {
  if (signalA.aborted) {
    return signalA;
  }
  if (signalB.aborted) {
    return signalB;
  }
  const controller = new AbortController();
  const onAbort = () => controller.abort();
  signalA.addEventListener("abort", onAbort, { once: true });
  signalB.addEventListener("abort", onAbort, { once: true });
  return controller.signal;
}


path: src/commands/activity.ts
content: import { Command } from "commander";
import { FitbitClient } from "../client/api.js";
import { resolveDate } from "../utils/date.js";
import { createStyler, writeOutput, keyValueTable, OutputOptions } from "../utils/output.js";

interface ActivityOptions extends OutputOptions {
  verbose?: boolean;
  tz?: string;
}

export function registerActivity(command: Command): void {
  const activity = command
    .command("activity [date]")
    .description("Show daily activity summary (default: today)")
    .option("--json", "Output as JSON")
    .option("--no-color", "Disable colored output")
    .option("--verbose", "Show debug output")
    .option("--tz <timezone>", "Override timezone")
    .action(async (date: string | undefined, options: ActivityOptions) => {
      const resolvedDate = resolveDate(date, options.tz);
      const client = await FitbitClient.load(options.verbose);
      const data = await client.getActivity(resolvedDate);
      const summary = data.summary;
      const goals = data.goals;

      if (options.json) {
        await writeOutput({ date: resolvedDate, summary, goals }, options);
        return;
      }

      const c = createStyler(options);
      const pct = (val: number, goal: number) => {
        if (!goal) {
          return "N/A";
        }
        const p = Math.round((val / goal) * 100);
        return p >= 100 ? c.green(`${p}%`) : c.yellow(`${p}%`);
      };

      console.log(c.bold(`\nActivity Summary for ${resolvedDate}\n`));
      console.log(keyValueTable([
        { key: "Steps", value: `${summary.steps.toLocaleString()} / ${goals.steps.toLocaleString()} (${pct(summary.steps, goals.steps)})` },
        { key: "Calories Out", value: `${summary.caloriesOut.toLocaleString()} / ${goals.caloriesOut.toLocaleString()} (${pct(summary.caloriesOut, goals.caloriesOut)})` },
        { key: "Distance", value: `${summary.distances.find(d => d.activity === "total")?.distance.toFixed(2) ?? "0"} km` },
        { key: "Floors", value: `${summary.floors} / ${goals.floors} (${pct(summary.floors, goals.floors)})` },
        { key: "Active Minutes", value: `${summary.fairlyActiveMinutes + summary.veryActiveMinutes} min (${summary.veryActiveMinutes} very active)` },
        { key: "Sedentary Minutes", value: `${summary.sedentaryMinutes} min` },
        { key: "Resting Heart Rate", value: summary.restingHeartRate != null ? `${summary.restingHeartRate} bpm` : "N/A" },
      ]));
    });

  // Subcommand: activity steps
  activity
    .command("steps [date]")
    .description("Show step count")
    .option("--json", "Output as JSON")
    .option("--tz <timezone>", "Override timezone")
    .option("--verbose", "Show debug output")
    .action(async (date: string | undefined, options: ActivityOptions) => {
      const resolvedDate = resolveDate(date, options.tz);
      const client = await FitbitClient.load(options.verbose);
      const data = await client.getActivity(resolvedDate);

      if (options.json) {
        await writeOutput({ date: resolvedDate, steps: data.summary.steps, goal: data.goals.steps }, options);
        return;
      }

      const goal = data.goals.steps;
      const pct = goal ? `${Math.round((data.summary.steps / goal) * 100)}%` : "N/A";
      console.log(`${resolvedDate}: ${data.summary.steps.toLocaleString()} steps (${pct} of ${goal.toLocaleString()} goal)`);
    });

  // Subcommand: activity calories
  activity
    .command("calories [date]")
    .description("Show calories burned")
    .option("--json", "Output as JSON")
    .option("--tz <timezone>", "Override timezone")
    .option("--verbose", "Show debug output")
    .action(async (date: string | undefined, options: ActivityOptions) => {
      const resolvedDate = resolveDate(date, options.tz);
      const client = await FitbitClient.load(options.verbose);
      const data = await client.getActivity(resolvedDate);

      if (options.json) {
        await writeOutput({ date: resolvedDate, caloriesOut: data.summary.caloriesOut, goal: data.goals.caloriesOut }, options);
        return;
      }

      console.log(`${resolvedDate}: ${data.summary.caloriesOut.toLocaleString()} calories burned`);
    });
}


path: src/commands/auth.ts
content: import { Command } from "commander";
import { readConfig } from "../storage/config.js";
import { readTokens, writeTokens, clearTokens } from "../storage/tokens.js";
import { startOAuthFlow, revokeToken } from "../client/auth.js";
import { createStyler, writeOutput, keyValueTable, OutputOptions } from "../utils/output.js";

export function registerAuth(command: Command): void {
  command
    .command("login")
    .description("Authenticate with Fitbit")
    .option("--verbose", "Show debug output")
    .action(async (options) => {
      const config = await readConfig();
      if (!config) {
        throw new Error("Run `fitbit configure` first to set your Client ID.");
      }
      const existing = await readTokens();
      if (existing && existing.expiresAt > Date.now()) {
        console.log("Already authenticated. Use `fitbit logout` to sign out first.");
        return;
      }
      console.log("Opening browser for Fitbit authorization...");
      const { tokens } = await startOAuthFlow(config, options.verbose);
      await writeTokens(tokens);
      console.log(`Authenticated as user ${tokens.userId}. Tokens saved.`);
    });

  command
    .command("logout")
    .description("Sign out and revoke tokens")
    .option("--verbose", "Show debug output")
    .action(async (options) => {
      const config = await readConfig();
      const tokens = await readTokens();
      if (!tokens) {
        console.log("Not currently authenticated.");
        return;
      }
      if (config) {
        await revokeToken(tokens.accessToken, config.clientId, options.verbose);
      }
      await clearTokens();
      console.log("Signed out and tokens cleared.");
    });

  command
    .command("status")
    .description("Show authentication status")
    .option("--json", "Output as JSON")
    .option("--no-color", "Disable colored output")
    .action(async (options: OutputOptions) => {
      const config = await readConfig();
      const tokens = await readTokens();
      const c = createStyler(options);

      if (options.json) {
        await writeOutput({
          configured: !!config,
          authenticated: !!tokens,
          userId: tokens?.userId ?? null,
          expiresAt: tokens?.expiresAt ?? null,
          scopes: tokens?.scopes ?? [],
        }, options);
        return;
      }

      if (!config) {
        console.log(c.yellow("Not configured. Run `fitbit configure` first."));
        return;
      }

      if (!tokens) {
        console.log(c.yellow("Not authenticated. Run `fitbit login`."));
        return;
      }

      const expiresIn = Math.max(0, Math.floor((tokens.expiresAt - Date.now()) / 1000 / 60));
      const expiryStatus = expiresIn > 5 ? c.green(`${expiresIn} min`) : c.red(`${expiresIn} min (expiring soon)`);

      console.log(keyValueTable([
        { key: "Status", value: c.green("Authenticated") },
        { key: "User ID", value: tokens.userId },
        { key: "Token expires in", value: expiryStatus },
        { key: "Scopes", value: tokens.scopes.join(", ") },
      ]));
    });
}


path: src/commands/configure.ts
content: import { Command } from "commander";
import { readConfig, writeConfig } from "../storage/config.js";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

export function registerConfigure(command: Command): void {
  command
    .command("configure")
    .description("Set Fitbit client configuration")
    .option("--client-id <id>", "Fitbit client ID")
    .option("--port <port>", "Callback port", "18787")
    .action(async (options) => {
      const existing = await readConfig();
      const clientId = options.clientId ?? (await promptForClientId(existing?.clientId));
      const port = Number(options.port || existing?.callbackPort || 18787);
      if (!Number.isInteger(port) || port < 1 || port > 65535) {
        throw new Error("Callback port must be an integer between 1 and 65535.");
      }
      if (!clientId) {
        throw new Error("Client ID is required. Get it from dev.fitbit.com.");
      }
      await writeConfig({ clientId, callbackPort: port });
      console.log("Fitbit client configuration saved.");
    });
}

async function promptForClientId(existing?: string): Promise<string> {
  const rl = createInterface({ input, output });
  try {
    const prompt = existing
      ? `Enter Fitbit Client ID (current: ${existing}): `
      : "Enter Fitbit Client ID (from dev.fitbit.com): ";
    const value = await rl.question(prompt);
    return value.trim() || existing || "";
  } finally {
    rl.close();
  }
}


path: src/commands/index.ts
content: import { Command } from "commander";
import { registerConfigure } from "./configure.js";
import { registerAuth } from "./auth.js";
import { registerProfile } from "./profile.js";
import { registerActivity } from "./activity.js";
import { registerSummary } from "./summary.js";

export function registerAllCommands(program: Command): void {
  registerConfigure(program);
  registerAuth(program);
  registerProfile(program);
  registerActivity(program);
  registerSummary(program);
}


path: src/commands/profile.ts
content: import { Command } from "commander";
import { FitbitClient } from "../client/api.js";
import { createStyler, writeOutput, keyValueTable, OutputOptions } from "../utils/output.js";

export function registerProfile(command: Command): void {
  command
    .command("profile")
    .description("Show user profile information")
    .option("--json", "Output as JSON")
    .option("--no-color", "Disable colored output")
    .option("--verbose", "Show debug output")
    .action(async (options: OutputOptions & { verbose?: boolean }) => {
      const client = await FitbitClient.load(options.verbose);
      const profile = await client.getProfile();
      const user = profile.user;

      if (options.json) {
        await writeOutput(user, options);
        return;
      }

      const c = createStyler(options);
      console.log(c.bold("\nFitbit Profile\n"));
      console.log(keyValueTable([
        { key: "Display Name", value: user.displayName },
        { key: "Full Name", value: user.fullName },
        { key: "User ID", value: user.encodedId },
        { key: "Age", value: String(user.age) },
        { key: "Gender", value: user.gender },
        { key: "Height", value: `${user.height} ${user.heightUnit}` },
        { key: "Weight", value: `${user.weight} ${user.weightUnit}` },
        { key: "Timezone", value: user.timezone },
      ]));
    });
}


path: src/commands/summary.ts
content: import { Command } from "commander";
import { FitbitClient } from "../client/api.js";
import { resolveDate } from "../utils/date.js";
import { createStyler, writeOutput, keyValueTable, OutputOptions } from "../utils/output.js";

interface SummaryOptions extends OutputOptions {
  verbose?: boolean;
  tz?: string;
}

export function registerSummary(command: Command): void {
  command
    .command("summary [date]")
    .description("Show full daily summary with all metrics")
    .option("--json", "Output as JSON")
    .option("--no-color", "Disable colored output")
    .option("--verbose", "Show debug output")
    .option("--tz <timezone>", "Override timezone")
    .action(async (date: string | undefined, options: SummaryOptions) => {
      const resolvedDate = resolveDate(date, options.tz);
      const client = await FitbitClient.load(options.verbose);

      const [activity, profile] = await Promise.all([
        client.getActivity(resolvedDate),
        client.getProfile(),
      ]);

      const summary = activity.summary;
      const goals = activity.goals;

      if (options.json) {
        await writeOutput({
          date: resolvedDate,
          user: profile.user.displayName,
          activity: summary,
          goals,
        }, options);
        return;
      }

      const c = createStyler(options);
      const pct = (val: number, goal: number) => {
        if (!goal) {
          return "N/A";
        }
        const p = Math.round((val / goal) * 100);
        return p >= 100 ? c.green(`${p}%`) : c.yellow(`${p}%`);
      };

      console.log(c.bold(`\n📊 Daily Summary for ${profile.user.displayName} — ${resolvedDate}\n`));

      console.log(c.underline("Activity"));
      console.log(keyValueTable([
        { key: "Steps", value: `${summary.steps.toLocaleString()} / ${goals.steps.toLocaleString()} (${pct(summary.steps, goals.steps)})` },
        { key: "Calories Out", value: `${summary.caloriesOut.toLocaleString()} kcal` },
        { key: "Distance", value: `${summary.distances.find(d => d.activity === "total")?.distance.toFixed(2) ?? "0"} km` },
        { key: "Floors", value: `${summary.floors}` },
      ]));

      console.log(c.underline("\nActive Time"));
      console.log(keyValueTable([
        { key: "Very Active", value: `${summary.veryActiveMinutes} min` },
        { key: "Fairly Active", value: `${summary.fairlyActiveMinutes} min` },
        { key: "Lightly Active", value: `${summary.lightlyActiveMinutes} min` },
        { key: "Sedentary", value: `${summary.sedentaryMinutes} min` },
      ]));

      if (summary.restingHeartRate != null) {
        console.log(c.underline("\nHeart"));
        console.log(keyValueTable([
          { key: "Resting Heart Rate", value: `${summary.restingHeartRate} bpm` },
        ]));
      }
    });

  // Shortcut: fitbit today
  command
    .command("today")
    .description("Shortcut for today's summary")
    .option("--json", "Output as JSON")
    .option("--no-color", "Disable colored output")
    .option("--verbose", "Show debug output")
    .option("--tz <timezone>", "Override timezone")
    .action(async (options: SummaryOptions) => {
      // Same logic as summary with no date
      const resolvedDate = resolveDate(undefined, options.tz);
      const client = await FitbitClient.load(options.verbose);

      const [activity, profile] = await Promise.all([
        client.getActivity(resolvedDate),
        client.getProfile(),
      ]);

      const summary = activity.summary;
      const goals = activity.goals;

      if (options.json) {
        await writeOutput({
          date: resolvedDate,
          user: profile.user.displayName,
          activity: summary,
          goals,
        }, options);
        return;
      }

      const c = createStyler(options);
      const pct = (val: number, goal: number) => {
        if (!goal) {
          return "N/A";
        }
        const p = Math.round((val / goal) * 100);
        return p >= 100 ? c.green(`${p}%`) : c.yellow(`${p}%`);
      };

      console.log(c.bold(`\n📊 Daily Summary for ${profile.user.displayName} — ${resolvedDate}\n`));

      console.log(c.underline("Activity"));
      console.log(keyValueTable([
        { key: "Steps", value: `${summary.steps.toLocaleString()} / ${goals.steps.toLocaleString()} (${pct(summary.steps, goals.steps)})` },
        { key: "Calories Out", value: `${summary.caloriesOut.toLocaleString()} kcal` },
        { key: "Distance", value: `${summary.distances.find(d => d.activity === "total")?.distance.toFixed(2) ?? "0"} km` },
        { key: "Floors", value: `${summary.floors}` },
      ]));

      console.log(c.underline("\nActive Time"));
      console.log(keyValueTable([
        { key: "Very Active", value: `${summary.veryActiveMinutes} min` },
        { key: "Fairly Active", value: `${summary.fairlyActiveMinutes} min` },
        { key: "Lightly Active", value: `${summary.lightlyActiveMinutes} min` },
        { key: "Sedentary", value: `${summary.sedentaryMinutes} min` },
      ]));

      if (summary.restingHeartRate != null) {
        console.log(c.underline("\nHeart"));
        console.log(keyValueTable([
          { key: "Resting Heart Rate", value: `${summary.restingHeartRate} bpm` },
        ]));
      }
    });
}


path: src/storage/config.ts
content: import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { ConfigData } from "../types/config.js";

const CONFIG_DIR = path.join(os.homedir(), ".config", "fitbit-cli");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");

export function getConfigPath(): string {
  return CONFIG_PATH;
}

export async function ensureConfigDir(): Promise<void> {
  await fs.mkdir(CONFIG_DIR, { recursive: true });
}

export async function readConfig(): Promise<ConfigData | null> {
  try {
    const raw = await fs.readFile(CONFIG_PATH, "utf8");
    const parsed = JSON.parse(raw) as ConfigData;
    if (!parsed.clientId) {
      return null;
    }
    return {
      clientId: parsed.clientId,
      callbackPort: parsed.callbackPort ?? 18787,
    };
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return null;
    }
    throw error;
  }
}

export async function writeConfig(config: ConfigData): Promise<void> {
  await ensureConfigDir();
  const payload: ConfigData = {
    clientId: config.clientId.trim(),
    callbackPort: config.callbackPort ?? 18787,
  };
  const tempPath = `${CONFIG_PATH}.tmp`;
  await fs.writeFile(tempPath, JSON.stringify(payload, null, 2), "utf8");
  await fs.rename(tempPath, CONFIG_PATH);
}


path: src/storage/tokens.ts
content: import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
import { TokenData } from "../types/config.js";

const CONFIG_DIR = path.join(os.homedir(), ".config", "fitbit-cli");
const TOKENS_PATH = path.join(CONFIG_DIR, "tokens.json");

export function getTokensPath(): string {
  return TOKENS_PATH;
}

export async function ensureTokensDir(): Promise<void> {
  await fs.mkdir(CONFIG_DIR, { recursive: true });
}

export async function readTokens(): Promise<TokenData | null> {
  try {
    const raw = await fs.readFile(TOKENS_PATH, "utf8");
    return JSON.parse(raw) as TokenData;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return null;
    }
    throw error;
  }
}

export async function writeTokens(tokens: TokenData): Promise<void> {
  await ensureTokensDir();
  const tempPath = `${TOKENS_PATH}.tmp`;
  await fs.writeFile(tempPath, JSON.stringify(tokens, null, 2), "utf8");
  await fs.rename(tempPath, TOKENS_PATH);
  await fs.chmod(TOKENS_PATH, 0o600);
}

export async function clearTokens(): Promise<void> {
  try {
    await fs.unlink(TOKENS_PATH);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") {
      return;
    }
    throw error;
  }
}


path: src/types/config.ts
content: export interface ConfigData {
  clientId: string;
  callbackPort: number;
}

export interface TokenData {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
  userId: string;
  scopes: string[];
}

export interface OAuthStateData {
  codeVerifier: string;
  state: string;
  createdAt: number;
}


path: src/types/fitbit.ts
content: export interface FitbitProfileResponse {
  user: {
    age: number;
    avatar: string;
    displayName: string;
    encodedId: string;
    fullName: string;
    gender: string;
    height: number;
    heightUnit: string;
    locale: string;
    timezone: string;
    weight: number;
    weightUnit: string;
  };
}

export interface FitbitActivityResponse {
  activities: Array<{
    activityId: number;
    activityParentId: number;
    activityParentName: string;
    activityName: string;
    calories: number;
    duration: number;
    distance: number;
    steps?: number;
  }>;
  goals: {
    activeMinutes: number;
    caloriesOut: number;
    distance: number;
    floors: number;
    steps: number;
  };
  summary: {
    activeScore: number;
    activityCalories: number;
    caloriesBMR: number;
    caloriesOut: number;
    distances: Array<{ activity: string; distance: number }>;
    elevation: number;
    fairlyActiveMinutes: number;
    floors: number;
    lightlyActiveMinutes: number;
    marginalCalories: number;
    restingHeartRate?: number;
    sedentaryMinutes: number;
    steps: number;
    veryActiveMinutes: number;
  };
}

export interface FitbitTokenResponse {
  access_token: string;
  expires_in: number;
  refresh_token: string;
  scope: string;
  token_type: string;
  user_id: string;
}

export interface FitbitErrorResponse {
  errors?: Array<{ errorType: string; message: string }>;
  success?: boolean;
}


path: src/utils/date.ts
content: import { format, isValid, parseISO, subDays, subMonths, subWeeks } from "date-fns";

export function resolveDate(input: string | undefined, timeZone?: string): string {
  if (!input) {
    return format(todayInTimeZone(timeZone), "yyyy-MM-dd");
  }
  if (input === "today") {
    return format(todayInTimeZone(timeZone), "yyyy-MM-dd");
  }
  const parsed = parseISO(input);
  if (!isValid(parsed)) {
    throw new Error(`Invalid date: ${input}. Use YYYY-MM-DD.`);
  }
  return format(parsed, "yyyy-MM-dd");
}

export function resolveRange(range: string | undefined, timeZone?: string): { start: string; end: string } {
  const end = todayInTimeZone(timeZone);
  if (!range) {
    return { start: format(end, "yyyy-MM-dd"), end: format(end, "yyyy-MM-dd") };
  }
  const match = range.match(/^(\d+)([dwm])$/);
  if (!match) {
    throw new Error(`Invalid range: ${range}. Use 7d, 4w, 3m.`);
  }
  const value = Number(match[1]);
  const unit = match[2];
  let startDate = end;
  if (unit === "d") {
    startDate = subDays(end, value);
  } else if (unit === "w") {
    startDate = subWeeks(end, value);
  } else if (unit === "m") {
    startDate = subMonths(end, value);
  }
  return {
    start: format(startDate, "yyyy-MM-dd"),
    end: format(end, "yyyy-MM-dd"),
  };
}

function todayInTimeZone(timeZone?: string): Date {
  if (!timeZone) {
    return new Date();
  }
  const formatter = new Intl.DateTimeFormat("en-US", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  });
  const parts = formatter.formatToParts(new Date());
  const year = Number(parts.find((part) => part.type === "year")?.value ?? "1970");
  const month = Number(parts.find((part) => part.type === "month")?.value ?? "01");
  const day = Number(parts.find((part) => part.type === "day")?.value ?? "01");
  return new Date(Date.UTC(year, month - 1, day));
}


path: src/utils/output.ts
content: import { promises as fs } from "node:fs";
import Table from "cli-table3";
import chalk from "chalk";

export interface OutputOptions {
  json?: boolean;
  noColor?: boolean;
  output?: string;
}

// Simple styler interface for the subset of chalk we use
export interface Styler {
  bold: (s: string) => string;
  green: (s: string) => string;
  yellow: (s: string) => string;
  red: (s: string) => string;
  underline: (s: string) => string;
}

const noopStyler: Styler = {
  bold: (s) => s,
  green: (s) => s,
  yellow: (s) => s,
  red: (s) => s,
  underline: (s) => s,
};

export function createStyler(options: OutputOptions): Styler {
  if (options.noColor) {
    return noopStyler;
  }
  return {
    bold: (s) => chalk.bold(s),
    green: (s) => chalk.green(s),
    yellow: (s) => chalk.yellow(s),
    red: (s) => chalk.red(s),
    underline: (s) => chalk.underline(s),
  };
}

export async function writeOutput(value: unknown, options: OutputOptions): Promise<void> {
  const text = options.json ? JSON.stringify(value, null, 2) : String(value);
  if (options.output) {
    await fs.writeFile(options.output, text, "utf8");
    return;
  }
  console.log(text);
}

export function keyValueTable(rows: Array<{ key: string; value: string }>): string {
  const table = new Table({ colWidths: [24, 60], wordWrap: true });
  rows.forEach((row) => table.push([row.key, row.value]));
  return table.toString();
}

export function listTable(headers: string[], rows: Array<string[]>): string {
  const table = new Table({ head: headers });
  rows.forEach((row) => table.push(row));
  return table.toString();
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"]
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)