Skill: ivanvmoreno/strong-app (version 1.1.0)

--- name: strong-app description: > Interact with the Strong v6 workout tracker REST API — login, list exercises, fetch workout logs and templates, manage folders, tags, measurements, and widgets. Use when the user asks about their Strong app workouts, exercises, or training data. version: 1.1.0 homepage: https://github.com/dmzoneill/strongapp-api metadata: clawdbot: requires: env: - STRONG_USERNAME - STRONG_PASSWORD bins: - python3 primaryEnv: STRONG_USERNAME emoji: "\U0001F4AA" files: - "scripts/*" --- # Strong Workout Tracker (v6 API) Unofficial REST API for the Strong workout tracking app (v6+). All API interaction goes through the CLI dispatcher at `scripts/strong_runner.py`. > **Warning:** This API is reverse-engineered and unofficial. Use at your own > risk. ## Environment Variables | Variable | Description | |---|---| | `STRONG_USERNAME` | Strong account username or email | | `STRONG_PASSWORD` | Strong account password | Both must be set before running any command. The CLI reads them automatically. ## Runner All commands are invoked via: ```bash python3 scripts/strong_runner.py <command> [--param value ...] ``` Every command authenticates automatically (login is handled internally), prints JSON to stdout, and exits. No manual token management is needed. ## Workflow 1. Pick the appropriate subcommand from the list below. 2. Run it — authentication and token handling happen inside the runner. 3. All collection responses use **HAL** format with `_links`, `_embedded`, and `total`. --- ## 1. Login Authenticate and return tokens. Rarely needed directly — all other commands log in automatically. ```bash python3 scripts/strong_runner.py login ``` **Output:** `{ "accessToken": "eyJ...", "refreshToken": "kf3Z...", "userId": "uuid" }` --- ## 2. Refresh Token Renew an expired access token without re-entering credentials. ```bash python3 scripts/strong_runner.py refresh_token ``` **Output:** `{ "accessToken": "eyJ...", "refreshToken": "...", "expiresIn": 1200, "userId": "uuid" }` --- ## 3. Get User Profile Fetch the authenticated user's profile, preferences, and purchases. ```bash python3 scripts/strong_runner.py get_profile ``` **Response keys:** `id`, `created`, `lastChanged`, `username`, `email`, `emailVerified`, `name`, `goal`, `preferences`, `purchases`, `legacyPurchase`, `legacyGoals`, `startHistoryFromDate`, `firstWeekDay`, `availableLogins`, `migrated`. --- ## 4. List Exercises (Measurements) Fetch all exercises in the user's library. ```bash python3 scripts/strong_runner.py list_exercises ``` **Response (HAL):** ```json { "_links": { "self": {...} }, "_embedded": { "measurement": [ { "id": "uuid", "created": "ISO8601", "lastChanged": "ISO8601", "name": { "custom": "Bench Press (Barbell)" }, "instructions": { "custom": "..." }, "media": [], "cellTypeConfigs": [{ "cellType": "...", "mandatory": true }], "measurementType": "EXERCISE" } ] }, "total": 360 } ``` --- ## 5. Get Single Exercise ```bash python3 scripts/strong_runner.py get_exercise --measurement_id <uuid> ``` | Parameter | Required | Description | |---|---|---| | `--measurement_id` | Yes | Exercise/measurement UUID | --- ## 6. List Workout Templates Fetch all workout templates (routines). ```bash python3 scripts/strong_runner.py list_templates ``` **Response (HAL):** ```json { "_embedded": { "template": [ { "id": "uuid", "created": "ISO8601", "lastChanged": "ISO8601", "name": { "custom": "Push Day" }, "access": "PRIVATE", "logType": "TEMPLATE", "_embedded": { "cellSetGroup": [ { "id": "uuid", "cellSets": [...] } ] } } ] }, "total": 75 } ``` --- ## 7. Get Single Template ```bash python3 scripts/strong_runner.py get_template --template_id <uuid> ``` | Parameter | Required | Description | |---|---|---| | `--template_id` | Yes | Template UUID | **Response keys:** `id`, `created`, `lastChanged`, `name`, `access`, `logType`, `_embedded.cellSetGroup[]` (each with `id`, `cellSets[]`). --- ## 8. List Workout Logs Fetch all completed workout sessions. ```bash python3 scripts/strong_runner.py list_logs ``` **Response (HAL):** ```json { "_embedded": { "log": [ { "id": "uuid", "created": "ISO8601", "lastChanged": "ISO8601", "name": { "custom": "Push Day" }, "access": "PUBLIC", "startDate": "ISO8601", "endDate": "ISO8601", "logType": "WORKOUT", "_embedded": { "cellSetGroup": [ { "id": "uuid", "cellSets": [...] } ] } } ] }, "total": 552 } ``` --- ## 9. Get Single Log Fetch a single workout log. Pass `--include_measurement` to embed exercise data. ```bash python3 scripts/strong_runner.py get_log --log_id <uuid> python3 scripts/strong_runner.py get_log --log_id <uuid> --include_measurement ``` | Parameter | Required | Description | |---|---|---| | `--log_id` | Yes | Log UUID | | `--include_measurement` | No | Include exercise/measurement data in the response | **Response keys:** `id`, `created`, `lastChanged`, `name`, `access`, `startDate`, `endDate`, `logType`, `_embedded.cellSetGroup[]` (each with `id`, `cellSets[]`, `_embedded.cellSet[]`). --- ## 10. List Folders Fetch workout template folders. ```bash python3 scripts/strong_runner.py list_folders ``` **Response (HAL):** `_embedded.folder[]` with keys: `id`, `created`, `lastChanged`, `name`, `index`. --- ## 11. List Tags Fetch exercise tags/categories. ```bash python3 scripts/strong_runner.py list_tags ``` **Response (HAL):** `_embedded.tag[]` with keys: `id`, `created`, `name`, `color`, `isGlobal`. --- ## 12. List Widgets Fetch dashboard widget configuration. ```bash python3 scripts/strong_runner.py list_widgets ``` **Response (HAL):** `_embedded.widget[]` with keys: `id`, `created`, `lastChanged`, `index`, `widgetType`, `parameters`. --- ## 13. Share a Template Generate a shareable link for a workout template. ```bash python3 scripts/strong_runner.py share_template --template_id <uuid> ``` | Parameter | Required | Description | |---|---|---| | `--template_id` | Yes | Template UUID | --- ## 14. Share a Workout Log Generate a shareable link for a workout log. ```bash python3 scripts/strong_runner.py share_log --log_id <uuid> ``` | Parameter | Required | Description | |---|---|---| | `--log_id` | Yes | Log UUID | --- ## 15. Get Shared Link Retrieve a shared template or log by its link ID. ```bash python3 scripts/strong_runner.py get_shared_link --link_id <id> ``` | Parameter | Required | Description | |---|---|---| | `--link_id` | Yes | Shared link ID | --- ## 16. Get Log at Date Fetch workout logs whose `startDate` falls on a specific date. Client-side filter over `list_logs`. ```bash python3 scripts/strong_runner.py get_log_at_date --date 2025-03-15 python3 scripts/strong_runner.py get_log_at_date --date 2025-03-15T14:30:00+01:00 ``` | Parameter | Required | Description | |---|---|---| | `--date` | Yes | Target date (`YYYY-MM-DD` or ISO-8601 timestamp) | **Response (HAL):** `_embedded.log[]` — only logs matching the given calendar date. --- ## 17. Get Logs in Range Fetch workout logs between two dates (inclusive). Client-side filter over `list_logs`. ```bash python3 scripts/strong_runner.py get_logs_in_range --from 2025-01-01 --to 2025-03-31 ``` | Parameter | Required | Description | |---|---|---| | `--from` | Yes | Start date (`YYYY-MM-DD` or ISO-8601) | | `--to` | Yes | End date (`YYYY-MM-DD` or ISO-8601) | **Response (HAL):** `_embedded.log[]` — logs sorted by `startDate`, within the range. --- ## 18. Get Latest Log Return the single most recent workout log by `startDate`. Client-side filter over `list_logs`. ```bash python3 scripts/strong_runner.py get_latest_log ``` **Response:** A single log object (not wrapped in `_embedded`). --- ## 19. Search Logs by Name Search workout logs by name using a case-insensitive substring match. Client-side filter over `list_logs`. ```bash python3 scripts/strong_runner.py search_logs_by_name --name "push day" ``` | Parameter | Required | Description | |---|---|---| | `--name` | Yes | Substring to match against log names | **Response (HAL):** `_embedded.log[]` — matching logs. --- ## 20. Search Exercises by Name Search exercises by name using a case-insensitive substring match. Client-side filter over `list_exercises`. ```bash python3 scripts/strong_runner.py search_exercises_by_name --name "bench press" ``` | Parameter | Required | Description | |---|---|---| | `--name` | Yes | Substring to match against exercise names | **Response (HAL):** `_embedded.measurement[]` — matching exercises. --- ## 21. Get Exercise History Return all workout logs that contain a specific exercise (by measurement ID). Client-side filter over `list_logs`. ```bash python3 scripts/strong_runner.py get_exercise_history --measurement_id <uuid> ``` | Parameter | Required | Description | |---|---|---| | `--measurement_id` | Yes | Exercise/measurement UUID to search for | **Response (HAL):** `_embedded.log[]` — logs containing the exercise, sorted by `startDate`. --- ## External endpoints | Endpoint | Purpose | |---|---| | `https://back.strong.app/auth/login` | Authenticate and obtain JWT tokens | | `https://back.strong.app/auth/login/refresh` | Refresh an expired access token | | `https://back.strong.app/api/users/{userId}` | User profile | | `https://back.strong.app/api/users/{userId}/measurements` | Exercises (measurements) | | `https://back.strong.app/api/users/{userId}/templates` | Workout templates | | `https://back.strong.app/api/users/{userId}/logs` | Workout logs | | `https://back.strong.app/api/users/{userId}/folders` | Template folders | | `https://back.strong.app/api/users/{userId}/tags` | Exercise tags | | `https://back.strong.app/api/users/{userId}/widgets` | Dashboard widgets | | `https://back.strong.app/api/links/{linkId}` | Shared links | No other external endpoints are contacted. --- ## Security and privacy - **Credentials** are read exclusively from `STRONG_USERNAME` and `STRONG_PASSWORD` environment variables and are never logged, cached, or written to disk. - **Tokens** (JWT access and refresh) are held in memory only for the duration of a single invocation and are never persisted. - All traffic uses **HTTPS only** to `back.strong.app`. - The runner script (`scripts/strong_runner.py`) is pure Python using only the standard library — no shell expansion or interpolation of user input occurs. - No local files are read or written. --- ## Model invocation note The agent should invoke this skill whenever the user asks about their Strong app workouts, exercises, templates, workout history, tags, folders, or training data. The agent should call `login` implicitly before any data-fetching command and never expose raw tokens to the user. Prefer `list_logs` and `list_templates` for broad queries, and `get_log --log_id ...` or `get_template --template_id ...` for specific lookups. --- ## Trust statement This skill is **unofficial** and reverse-engineered. It is not affiliated with, endorsed by, or supported by Strong Fitness Ltd. The API surface may change without notice. Use at your own risk. The author assumes no liability for data loss or account restrictions resulting from use of this skill.

path: README.md
content: # Strong Workout Tracker OpenClaw Skill

[![CI](https://github.com/ivanvmoreno/strong-skill/actions/workflows/ci.yml/badge.svg)](https://github.com/ivanvmoreno/strong-skill/actions/workflows/ci.yml)
[![Publish](https://github.com/ivanvmoreno/strong-skill/actions/workflows/release.yml/badge.svg)](https://github.com/ivanvmoreno/strong-skill/actions/workflows/release.yml)
[![Strong App](https://img.shields.io/badge/Strong-v6_API-orange)](https://www.strong.app/)
[![OpenClaw Skill](https://img.shields.io/badge/OpenClaw-Skill-blue)](https://docs.openclaw.ai/tools/skills)

Interact with the Strong v6 workout tracker REST API — login, list exercises, fetch workout logs and templates, manage folders, tags, measurements, and widgets.

## Installation

### Via ClawHub

```
clawhub install strong-app
```

### Manual

Copy the skill folder to one of:

```
~/.openclaw/skills/strong-app/
<workspace>/skills/strong-app/
```

## Prerequisites

Python 3.8+ (standard library only — no extra packages required).

Set environment variables:

| Variable | Required | Description |
|---|---|---|
| `STRONG_USERNAME` | Yes | Strong account username or email |
| `STRONG_PASSWORD` | Yes | Strong account password |

## Usage

The skill is invoked automatically by the agent when it matches your request. You can also invoke it directly:

```
python3 scripts/strong_runner.py login
python3 scripts/strong_runner.py get_profile
python3 scripts/strong_runner.py list_logs
python3 scripts/strong_runner.py get_log --log_id <uuid>
python3 scripts/strong_runner.py list_templates
python3 scripts/strong_runner.py list_exercises
```

All commands print JSON to stdout.

## Supported Operations

- **Auth** — `login`, `refresh_token`
- **Profile** — `get_profile`
- **Exercises** — `list_exercises`, `get_exercise`
- **Templates** — `list_templates`, `get_template`
- **Logs** — `list_logs`, `get_log`
- **Folders** — `list_folders`
- **Tags** — `list_tags`
- **Widgets** — `list_widgets`
- **Sharing** — `share_template`, `share_log`, `get_shared_link`
- **Filtering** — `get_log_at_date`, `get_logs_in_range`, `get_latest_log`, `search_logs_by_name`, `search_exercises_by_name`, `get_exercise_history`

See [SKILL.md](SKILL.md) for full parameter details per command.

## Security

- All API calls go to `back.strong.app` over HTTPS only.
- Credentials are read from environment variables and never logged.
- The script is pure Python (stdlib only) — no shell expansion or interpolation of user input.
- See the security manifest in `scripts/strong_runner.py` for full details.

## Releasing

Releases are automated via GitHub Actions. To publish a new version:

1. Update the `version` field in `SKILL.md` frontmatter.
2. Commit and push to `main`.
3. Tag the commit and push the tag:

```bash
git tag v2.0.0
git push origin v2.0.0
```

The `release.yml` workflow will:
- Validate the skill package (required files, frontmatter, security manifests)
- Publish to ClawHub via `clawhub publish`
- Create a GitHub Release with a zip archive

### Required repository secret

Add `CLAWHUB_TOKEN` to your repo secrets (**Settings > Secrets > Actions**). Generate it by running `clawhub login` locally and copying the token.

### CI on every push/PR

The `ci.yml` workflow runs on every push to `main` and every PR. It validates the skill structure and tests that all subcommands in `strong_runner.py` are parseable and respond to `--help`.

## Setting up the new repo

This skill is designed to live in its own repo (e.g. `strong-skill`). To set it up:

```bash
git init
git add .
git commit -m "Initial commit"
git remote add origin [REDACTED_SECRET]:ivanvmoreno/strong-skill.git
git push -u origin main
```

The repo structure should be:

```
strong-skill/
├── .github/
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── SKILL.md
├── README.md
└── scripts/
    └── strong_runner.py
```

## References

- **[OpenClaw Skills Documentation](https://docs.openclaw.ai/tools/skills)** — Official docs on skill structure, discovery, eligibility, and configuration
- **[ClawHub Skill Format Spec](https://github.com/openclaw/clawhub/blob/main/docs/skill-format.md)** — SKILL.md frontmatter fields, allowed files, slugs, and versioning
- **[ClawHub Publishing Checklist](https://gist.github.com/adhishthite/0db995ecfe2f23e09d0b2d418491982c)** — 13-point checklist for publishing skills (frontmatter gotchas, security manifests, required sections)
- **[ClawHub README & CLI Reference](https://github.com/openclaw/clawhub)** — `clawhub publish`, `clawhub install`, auth flows, and registry API
- **[Building Custom OpenClaw Skills](https://lumadock.com/tutorials/build-custom-openclaw-skills)** — Practical guide on SKILL.md structure, runbook-style body, gating, and production templates
- **[What are OpenClaw Skills? (DigitalOcean)](https://www.digitalocean.com/resources/articles/what-are-openclaw-skills)** — Overview of the skill ecosystem, installation, and security considerations
- **[Strong App](https://www.strong.app/)** — Official Strong workout tracker app
- **[Strong API (unofficial)](https://github.com/dmzoneill/strongapp-api)** — Reverse-engineered API documentation this skill is based on


path: scripts/strong_runner.py
content: #!/usr/bin/env python3
# SECURITY MANIFEST:
# Environment variables accessed: STRONG_USERNAME, STRONG_PASSWORD (only)
# External endpoints called: https://back.strong.app (only)
# Local files read: none
# Local files written: none
"""
Strong v6 API CLI dispatcher for the OpenClaw skill.

Usage:
    python3 strong_runner.py <command> [--param value ...]

Environment variables:
    STRONG_USERNAME  (required)
    STRONG_PASSWORD  (required)
"""

import argparse
from datetime import datetime, timezone
import json
import os
import sys
import urllib.request
import urllib.error


STRONG_BASE = "https://back.strong.app"

COMMON_HEADERS = {
    "Content-Type": "application/json",
    "User-Agent": "okhttp/4.12.0",
    "X-Client-Platform": "android",
    "X-Client-Build": "1118",
}


def _output(data):
    """Print result as JSON to stdout."""
    print(json.dumps(data, indent=2, default=str))


def _request(method, path, headers=None, body=None):
    """Send an HTTP request and return parsed JSON."""
    url = f"{STRONG_BASE}{path}"
    all_headers = dict(COMMON_HEADERS)
    if headers:
        all_headers.update(headers)
    data = json.dumps(body).encode("utf-8") if body else None
    req = urllib.request.Request(url, data=data, headers=all_headers, method=method)
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as exc:
        error_body = exc.read().decode("utf-8", errors="replace")
        try:
            detail = json.loads(error_body)
        except json.JSONDecodeError:
            detail = error_body
        print(
            json.dumps({"error": f"HTTP {exc.code}", "detail": detail}),
            file=sys.stderr,
        )
        sys.exit(1)


def _login():
    """Authenticate and return (access_token, refresh_token, user_id)."""
    username = os.environ.get("STRONG_USERNAME")
    [REDACTED_SECRET]"STRONG_PASSWORD")
    if not username or not [REDACTED_SECRET]
            json.dumps(
                {"error": "STRONG_USERNAME and STRONG_PASSWORD must be set."}
            ),
            file=sys.stderr,
        )
        sys.exit(1)
    resp = _request("POST", "/auth/login", body={
        "login": username,
        "password": password,
        "usernameOrEmail": username,
    })
    return resp["accessToken"], resp["refreshToken"], resp["userId"]


def _auth_headers(token):
    """Return Authorization header dict."""
    return {"Authorization": f"Bearer {token}"}


# ---------------------------------------------------------------------------
# Command handlers
# ---------------------------------------------------------------------------

def cmd_login(_args):
    access_token, refresh_token, user_id = _login()
    _output({
        "accessToken": access_token,
        "refreshToken": refresh_token,
        "userId": user_id,
    })


def cmd_refresh_token(args):
    access_token, refresh_token, _user_id = _login()
    resp = _request(
        "POST",
        "/auth/login/refresh",
        headers=_auth_headers(access_token),
        body={
            "accessToken": access_token,
            "refreshToken": refresh_token,
        },
    )
    _output(resp)


def cmd_get_profile(_args):
    token, _, user_id = _login()
    resp = _request("GET", f"/api/users/{user_id}", headers=_auth_headers(token))
    _output(resp)


def cmd_list_exercises(_args):
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/measurements", headers=_auth_headers(token)
    )
    _output(resp)


def cmd_get_exercise(args):
    token, _, user_id = _login()
    resp = _request(
        "GET",
        f"/api/users/{user_id}/measurements/{args.measurement_id}",
        headers=_auth_headers(token),
    )
    _output(resp)


def cmd_list_templates(_args):
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/templates", headers=_auth_headers(token)
    )
    _output(resp)


def cmd_get_template(args):
    token, _, user_id = _login()
    resp = _request(
        "GET",
        f"/api/users/{user_id}/templates/{args.template_id}",
        headers=_auth_headers(token),
    )
    _output(resp)


def cmd_list_logs(_args):
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/logs", headers=_auth_headers(token)
    )
    _output(resp)


def cmd_get_log(args):
    token, _, user_id = _login()
    path = f"/api/users/{user_id}/logs/{args.log_id}"
    if args.include_measurement:
        path += "?include=measurement"
    resp = _request("GET", path, headers=_auth_headers(token))
    _output(resp)


def cmd_list_folders(_args):
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/folders", headers=_auth_headers(token)
    )
    _output(resp)


def cmd_list_tags(_args):
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/tags", headers=_auth_headers(token)
    )
    _output(resp)


def cmd_list_widgets(_args):
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/widgets", headers=_auth_headers(token)
    )
    _output(resp)


def cmd_share_template(args):
    token, _, user_id = _login()
    resp = _request(
        "POST",
        f"/api/users/{user_id}/templates/{args.template_id}/link",
        headers=_auth_headers(token),
    )
    _output(resp)


def cmd_share_log(args):
    token, _, user_id = _login()
    resp = _request(
        "POST",
        f"/api/users/{user_id}/logs/{args.log_id}/link",
        headers=_auth_headers(token),
    )
    _output(resp)


def cmd_get_shared_link(args):
    token, _, _ = _login()
    resp = _request(
        "GET",
        f"/api/links/{args.link_id}",
        headers=_auth_headers(token),
    )
    _output(resp)


# ---------------------------------------------------------------------------
# Helpers for augmented (client-side) commands
# ---------------------------------------------------------------------------

def _parse_date(value):
    """Parse a date string into a timezone-aware datetime.

    Accepts ISO-8601 timestamps or plain YYYY-MM-DD dates.
    """
    for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z",
                 "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S",
                 "%Y-%m-%d"):
        try:
            dt = datetime.strptime(value, fmt)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return dt
        except ValueError:
            continue
    raise ValueError(f"Unable to parse date: {value}")


def _fetch_all_logs():
    """Login, fetch all logs, and return the list."""
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/logs", headers=_auth_headers(token)
    )
    return token, user_id, resp.get("_embedded", {}).get("log", [])


# ---------------------------------------------------------------------------
# Augmented command handlers (client-side filtering)
# ---------------------------------------------------------------------------

def cmd_get_log_at_date(args):
    """Return logs whose startDate falls on the given date."""
    target = _parse_date(args.date)
    _, _, logs = _fetch_all_logs()
    matched = []
    for log in logs:
        start = log.get("startDate")
        if not start:
            continue
        log_dt = _parse_date(start)
        if log_dt.date() == target.date():
            matched.append(log)
    _output({"_embedded": {"log": matched}, "total": len(matched)})


def cmd_get_logs_in_range(args):
    """Return logs whose startDate is between --from and --to (inclusive)."""
    dt_from = _parse_date(args.date_from)
    dt_to = _parse_date(args.date_to)
    _, _, logs = _fetch_all_logs()
    matched = []
    for log in logs:
        start = log.get("startDate")
        if not start:
            continue
        log_dt = _parse_date(start)
        if dt_from <= log_dt <= dt_to:
            matched.append(log)
    matched.sort(key=lambda l: l.get("startDate", ""))
    _output({"_embedded": {"log": matched}, "total": len(matched)})


def cmd_get_latest_log(_args):
    """Return the most recent workout log by startDate."""
    _, _, logs = _fetch_all_logs()
    if not logs:
        _output({"_embedded": {"log": []}, "total": 0})
        return
    latest = max(logs, key=lambda l: l.get("startDate", ""))
    _output(latest)


def cmd_search_logs_by_name(args):
    """Return logs whose name contains the search string (case-insensitive)."""
    needle = args.name.lower()
    _, _, logs = _fetch_all_logs()
    matched = [
        log for log in logs
        if needle in (log.get("name", {}).get("custom", "") or "").lower()
    ]
    _output({"_embedded": {"log": matched}, "total": len(matched)})


def cmd_search_exercises_by_name(args):
    """Return exercises whose name contains the search string (case-insensitive)."""
    needle = args.name.lower()
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/measurements", headers=_auth_headers(token)
    )
    exercises = resp.get("_embedded", {}).get("measurement", [])
    matched = [
        ex for ex in exercises
        if needle in (ex.get("name", {}).get("custom", "") or "").lower()
    ]
    _output({"_embedded": {"measurement": matched}, "total": len(matched)})


def cmd_get_exercise_history(args):
    """Return all logs containing a specific exercise (measurement ID)."""
    mid = args.measurement_id
    token, _, user_id = _login()
    resp = _request(
        "GET", f"/api/users/{user_id}/logs", headers=_auth_headers(token)
    )
    logs = resp.get("_embedded", {}).get("log", [])
    matched = []
    for log in logs:
        groups = log.get("_embedded", {}).get("cellSetGroup", [])
        for group in groups:
            cells = group.get("cellSets", [])
            embedded_cells = group.get("_embedded", {}).get("cellSet", [])
            all_cells = cells + embedded_cells
            for cell in all_cells:
                if cell.get("measurementId") == mid:
                    matched.append(log)
                    break
            else:
                continue
            break
    matched.sort(key=lambda l: l.get("startDate", ""))
    _output({"_embedded": {"log": matched}, "total": len(matched)})


# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Strong v6 API CLI dispatcher for the OpenClaw skill."
    )
    subs = parser.add_subparsers(dest="command", required=True)

    # --- Auth ---
    subs.add_parser("login", help="Authenticate and obtain tokens")
    subs.add_parser("refresh_token", help="Refresh an expired access token")

    # --- Profile ---
    subs.add_parser("get_profile", help="Fetch the authenticated user's profile")

    # --- Exercises (measurements) ---
    subs.add_parser("list_exercises", help="List all exercises in the user's library")

    p = subs.add_parser("get_exercise", help="Get a single exercise by ID")
    p.add_argument("--measurement_id", required=True, help="Exercise/measurement UUID")

    # --- Templates ---
    subs.add_parser("list_templates", help="List all workout templates")

    p = subs.add_parser("get_template", help="Get a single workout template")
    p.add_argument("--template_id", required=True, help="Template UUID")

    # --- Logs ---
    subs.add_parser("list_logs", help="List all completed workout logs")

    p = subs.add_parser("get_log", help="Get a single workout log")
    p.add_argument("--log_id", required=True, help="Log UUID")
    p.add_argument(
        "--include_measurement",
        action="store_true",
        default=False,
        help="Include exercise/measurement data in the response",
    )

    # --- Folders ---
    subs.add_parser("list_folders", help="List workout template folders")

    # --- Tags ---
    subs.add_parser("list_tags", help="List exercise tags/categories")

    # --- Widgets ---
    subs.add_parser("list_widgets", help="List dashboard widgets")

    # --- Sharing ---
    p = subs.add_parser("share_template", help="Generate a shareable template link")
    p.add_argument("--template_id", required=True, help="Template UUID")

    p = subs.add_parser("share_log", help="Generate a shareable log link")
    p.add_argument("--log_id", required=True, help="Log UUID")

    p = subs.add_parser("get_shared_link", help="Retrieve a shared link by ID")
    p.add_argument("--link_id", required=True, help="Shared link ID")

    # --- Augmented (client-side filtering) ---
    p = subs.add_parser(
        "get_log_at_date",
        help="Get workout logs whose startDate falls on a given date",
    )
    p.add_argument(
        "--date", required=True,
        help="Target date (YYYY-MM-DD or ISO-8601 timestamp)",
    )

    p = subs.add_parser(
        "get_logs_in_range",
        help="Get workout logs between two dates (inclusive)",
    )
    p.add_argument(
        "--from", required=True, dest="date_from",
        help="Start date (YYYY-MM-DD or ISO-8601)",
    )
    p.add_argument(
        "--to", required=True, dest="date_to",
        help="End date (YYYY-MM-DD or ISO-8601)",
    )

    subs.add_parser(
        "get_latest_log",
        help="Get the most recent workout log by startDate",
    )

    p = subs.add_parser(
        "search_logs_by_name",
        help="Search workout logs by name (case-insensitive substring match)",
    )
    p.add_argument(
        "--name", required=True,
        help="Substring to match against log names",
    )

    p = subs.add_parser(
        "search_exercises_by_name",
        help="Search exercises by name (case-insensitive substring match)",
    )
    p.add_argument(
        "--name", required=True,
        help="Substring to match against exercise names",
    )

    p = subs.add_parser(
        "get_exercise_history",
        help="Get all workout logs containing a specific exercise",
    )
    p.add_argument(
        "--measurement_id", required=True,
        help="Exercise/measurement UUID to search for in logs",
    )

    return parser


# ---------------------------------------------------------------------------
# Dispatch table
# ---------------------------------------------------------------------------

COMMANDS = {
    "login": cmd_login,
    "refresh_token": cmd_refresh_token,
    "get_profile": cmd_get_profile,
    "list_exercises": cmd_list_exercises,
    "get_exercise": cmd_get_exercise,
    "list_templates": cmd_list_templates,
    "get_template": cmd_get_template,
    "list_logs": cmd_list_logs,
    "get_log": cmd_get_log,
    "list_folders": cmd_list_folders,
    "list_tags": cmd_list_tags,
    "list_widgets": cmd_list_widgets,
    "share_template": cmd_share_template,
    "share_log": cmd_share_log,
    "get_shared_link": cmd_get_shared_link,
    "get_log_at_date": cmd_get_log_at_date,
    "get_logs_in_range": cmd_get_logs_in_range,
    "get_latest_log": cmd_get_latest_log,
    "search_logs_by_name": cmd_search_logs_by_name,
    "search_exercises_by_name": cmd_search_exercises_by_name,
    "get_exercise_history": cmd_get_exercise_history,
}


def main():
    parser = build_parser()
    args = parser.parse_args()

    handler = COMMANDS.get(args.command)
    if handler is None:
        print(
            json.dumps({"error": f"Unknown command: {args.command}"}),
            file=sys.stderr,
        )
        sys.exit(1)

    try:
        handler(args)
    except Exception as exc:
        print(
            json.dumps({"error": str(exc), "type": type(exc).__name__}),
            file=sys.stderr,
        )
        sys.exit(1)


if __name__ == "__main__":
    main()


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