Skill: dennisooki/trakt-readonly (version 1.0.3)

--- name: trakt-readonly description: Read-only Trakt.tv skill for checking a user’s currently watching item, recent episode history, watched shows list, stats, profile, and playback progress (OAuth) using the Trakt API. Use when the user asks about their Trakt activity, watching status, or recent episodes. Requires a Trakt Client ID and username. metadata: {"openclaw":{"requires":{"env":["TRAKT_CLIENT_ID","TRAKT_USERNAME"],"bins":["curl","jq"]},"primaryEnv":"TRAKT_CLIENT_ID","emoji":"📺"}} --- # Trakt (OpenClaw) — Read-only Use this skill to query Trakt.tv user data via the API (read-only). Default to **read-only**; do not implement write operations unless the user explicitly asks for OAuth support. ## Requirements - Env vars: - `TRAKT_CLIENT_ID` (Trakt API Client ID) - `TRAKT_USERNAME` (Trakt username or user slug) - `TRAKT_ACCESS_TOKEN` (OAuth Bearer token, required for playback) - `TRAKT_CLIENT_SECRET` (required for device token exchange) - Binaries: `curl`, `jq` ## Commands (script) Use `{baseDir}/scripts/trakt-api.sh`. - `watching` — current movie/episode being watched - `recent [limit]` — recent episode history (default 10, max 100) - `watched-shows` — recently watched shows list - `profile` — user profile - `stats` — user stats - `playback <type> <start_at> <end_at>` — playback progress (OAuth required) Examples: ``` TRAKT_CLIENT_ID=xxx TRAKT_USERNAME=user {baseDir}/scripts/trakt-api.sh watching TRAKT_CLIENT_ID=xxx TRAKT_USERNAME=user {baseDir}/scripts/trakt-api.sh recent 5 TRAKT_CLIENT_ID=xxx TRAKT_ACCESS_TOKEN=yyy {baseDir}/scripts/trakt-api.sh playback movies 2016-06-01T00:00:00.000Z 2016-07-01T23:59:59.000Z TRAKT_CLIENT_ID=xxx {baseDir}/scripts/trakt-api.sh device-code TRAKT_CLIENT_ID=xxx TRAKT_CLIENT_SECRET=zzz {baseDir}/scripts/trakt-api.sh device-token <device_code> ``` ## Guardrails - Never log or expose API keys or access tokens. - Only call `https://api.trakt.tv`. - Read-only endpoints only; playback uses OAuth token read access. - Device OAuth flow is read-only; do not request write scopes. - Validate `recent` limit is numeric and between 1–100. - Handle empty/404 responses gracefully (public profile required). ## References - Read `references/trakt-api.md` for endpoints, headers, and auth notes.

path: README.md
content: # Trakt Read-only Skill

A secure OpenClaw skill for querying Trakt.tv user data (read-only): current watching, recent episode history, watched shows, stats, and profile.

## Features

- **Watching**: Current movie or episode
- **Recent Episodes**: Recent episode history
- **Watched Shows**: Recently watched shows list
- **Stats**: User stats
- **Profile**: User profile info

## Prerequisites

- `jq` (for JSON parsing)
- `curl`

Install on Ubuntu/Debian:

```
sudo apt-get update && sudo apt-get install -y jq curl
```

## Setup

### Get a Trakt Client ID

1. Go to https://trakt.tv/oauth/applications
2. Click **New Application**
3. Fill in the form (any name). For redirect URI, you can use `urn:ietf:wg:oauth:2.0:oob`.
4. Save and copy the **Client ID**

### Configure OpenClaw

Add to `~/.openclaw/openclaw.json`:

```json5
{
  skills: {
    entries: {
      "trakt-readonly": {
        enabled: true,
        [REDACTED_SECRET]", // convenience for TRAKT_CLIENT_ID
        env: {
          TRAKT_CLIENT_ID: "YOUR_TRAKT_CLIENT_ID",
          TRAKT_USERNAME: "YOUR_TRAKT_USERNAME",
          TRAKT_ACCESS_TOKEN: "YOUR_TRAKT_OAUTH_TOKEN", // required for playback
          TRAKT_CLIENT_SECRET: "YOUR_TRAKT_CLIENT_SECRET" // required for device token exchange
        }
      }
    }
  }
}
```

**Note:** OpenClaw injects these env vars at runtime (not globally). Never commit secrets to git. If you run the agent in a sandboxed session, set env vars under `agents.defaults.sandbox.docker.env`.

## Usage

### Read-only Commands (No OAuth Required)

**Read-only endpoints require only `TRAKT_CLIENT_ID` + `TRAKT_USERNAME`.**

| Command | Description | Example |
|---------|-------------|---------|
| `watching` | Current watching | `/trakt-readonly watching` |
| `recent [limit]` | Recent episodes | `/trakt-readonly recent 5` |
| `watched-shows` | Watched shows list | `/trakt-readonly watched-shows` |
| `stats` | User stats | `/trakt-readonly stats` |
| `profile` | User profile | `/trakt-readonly profile` |

## Playback progress (OAuth required)

Trakt playback progress requires OAuth. Configure `TRAKT_ACCESS_TOKEN` and use:

```
/trakt-readonly playback movies 2016-06-01T00:00:00.000Z 2016-07-01T23:59:59.000Z
```

## Device OAuth (activate)

1) Start device flow:
```
/trakt-readonly device-code
```
2) Visit https://trakt.tv/activate and enter the `user_code`.
3) Exchange the `device_code` for a token:
```
/trakt-readonly device-token <device_code>
```

## Security

- No hardcoded credentials
- Only connects to `https://api.trakt.tv`
- Read-only endpoints only; playback uses OAuth token read access
- Public Trakt profile required for read-only access

## References

- `references/trakt-api.md`


path: references/trakt-api.md
content: # Trakt API (Read-only) Quick Reference

Base URL: `https://api.trakt.tv`

Headers (required for all requests):
- `Content-Type: application/json`
- `trakt-api-version: 2`
- `trakt-[REDACTED_SECRET]`

## Read-only endpoints used by this skill

### Playback progress (OAuth required)
`GET /sync/playback/{type}?start_at=...&end_at=...`

Headers:
- `Authorization: Bearer <TRAKT_ACCESS_TOKEN>`
- `Content-Type: application/json`
- `trakt-api-version: 2`
- `trakt-[REDACTED_SECRET]`

Notes:
- `type`: `movies` or `episodes`
- `start_at`/`end_at`: ISO timestamps

---

- Current watching:
  - `GET /users/{username}/watching`
- Recent episode history:
  - `GET /users/{username}/history/episodes?limit=10`
- Watched shows list:
  - `GET /users/{username}/watched/shows`
- User profile:
  - `GET /users/{username}`
- User stats:
  - `GET /users/{username}/stats`

## Auth

Read-only endpoints above require **only** a Trakt **Client ID** (API key). OAuth is required for playback progress and device flow (read-only).

### Device OAuth
- `POST /oauth/device/code` with `{ client_id }`
- `POST /oauth/device/token` with `{ code, client_id, client_secret }`

User completes activation at https://trakt.tv/activate.

## Notes

- `username` can be the Trakt username or user slug.
- Public profiles are required for access to user data without OAuth.


path: scripts/trakt-api.sh
content: #!/bin/bash

set -euo pipefail

TRAKT_API_ROOT="https://api.trakt.tv"

print_usage() {
    cat <<EOF
Usage: trakt-api.sh <command> [options]

Commands:
    watching                 Get currently watching (movie or episode)
    recent [limit]           Recent episode history (default: 10, max: 100)
    watched-shows            List recently watched shows
    stats                    User stats summary
    profile                  User profile info
    playback <type> <start_at> <end_at>   Playback progress (requires token)
    device-code             Start device OAuth (gives user_code)
    device-token <device_code>  Exchange device code for access token

Environment Variables Required:
    TRAKT_CLIENT_ID          Your Trakt API client id
    TRAKT_USERNAME           Your Trakt username or user slug

Optional (for playback):
    TRAKT_ACCESS_TOKEN       OAuth access token (Bearer)
    TRAKT_CLIENT_SECRET      OAuth client secret (required for device token exchange)

Examples:
    TRAKT_CLIENT_ID=xxx TRAKT_USERNAME=user ./trakt-api.sh watching
    TRAKT_CLIENT_ID=xxx TRAKT_USERNAME=user ./trakt-api.sh recent 5
    TRAKT_CLIENT_ID=xxx TRAKT_ACCESS_TOKEN=yyy ./trakt-api.sh playback movies 2016-06-01T00:00:00.000Z 2016-07-01T23:59:59.000Z
    TRAKT_CLIENT_ID=xxx ./trakt-api.sh device-code
    TRAKT_CLIENT_ID=xxx TRAKT_CLIENT_SECRET=zzz ./trakt-api.sh device-token <device_code>
EOF
}

check_required_vars() {
    if [[ -z "${TRAKT_CLIENT_ID:-}" ]]; then
        echo "Error: TRAKT_CLIENT_ID not set" >&2
        exit 1
    fi
    if [[ -z "${TRAKT_USERNAME:-}" ]]; then
        echo "Error: TRAKT_USERNAME not set" >&2
        exit 1
    fi
}

check_bins() {
    local bins=("curl" "jq")
    for b in "${bins[@]}"; do
        if ! command -v "$b" >/dev/null 2>&1; then
            echo "Error: missing dependency '$b'" >&2
            exit 1
        fi
    done
}

validate_limit() {
    local limit="$1"
    if [[ ! "$limit" =~ ^[0-9]+$ ]]; then
        echo "Error: limit must be a number" >&2
        exit 1
    fi
    if (( limit < 1 || limit > 100 )); then
        echo "Error: limit must be between 1 and 100" >&2
        exit 1
    fi
}

api_get() {
    local endpoint="$1"
    shift
    local params="$*"

    local url="${TRAKT_API_ROOT}${endpoint}"
    if [[ -n "$params" ]]; then
        url+="?${params}"
    fi

    curl -s \
      -H "Content-Type: application/json" \
      -H "trakt-api-version: 2" \
      -H "trakt-[REDACTED_SECRET]" \
      "$url"
}

api_get_auth() {
    local endpoint="$1"
    shift
    local params="$*"

    if [[ -z "${TRAKT_ACCESS_TOKEN:-}" ]]; then
        echo "Error: TRAKT_ACCESS_TOKEN not set (required for playback)" >&2
        exit 1
    fi

    local url="${TRAKT_API_ROOT}${endpoint}"
    if [[ -n "$params" ]]; then
        url+="?${params}"
    fi

    curl -s \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer ${TRAKT_ACCESS_TOKEN}" \
      -H "trakt-api-version: 2" \
      -H "trakt-[REDACTED_SECRET]" \
      "$url"
}

oauth_post() {
    local endpoint="$1"
    local body="$2"

    curl -s \
      -H "Content-Type: application/json" \
      -H "trakt-api-version: 2" \
      -H "trakt-[REDACTED_SECRET]" \
      -d "$body" \
      "${TRAKT_API_ROOT}${endpoint}"
}

format_watching() {
    local json="$1"
    if [[ -z "$json" || "$json" == "null" ]]; then
        echo "Nothing currently watching"
        return
    fi

    local type
    type=$(echo "$json" | jq -r '.type // empty')

    if [[ -z "$type" ]]; then
        echo "Nothing currently watching"
        return
    fi

    if [[ "$type" == "episode" ]]; then
        local show
        show=$(echo "$json" | jq -r '.show.title')
        local title
        title=$(echo "$json" | jq -r '.episode.title')
        local season
        season=$(echo "$json" | jq -r '.episode.season')
        local number
        number=$(echo "$json" | jq -r '.episode.number')
        echo "📺 Watching: ${show}"
        printf 'S%02dE%02d — %s\n' "$season" "$number" "$title"
    elif [[ "$type" == "movie" ]]; then
        local movie
        movie=$(echo "$json" | jq -r '.movie.title')
        local year
        year=$(echo "$json" | jq -r '.movie.year // empty')
        if [[ -n "$year" ]]; then
            echo "🎬 Watching: ${movie} (${year})"
        else
            echo "🎬 Watching: ${movie}"
        fi
    else
        echo "Watching: ${type}"
    fi
}

format_recent() {
    local json="$1"
    local limit="$2"

    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi

    echo "📺 Recent Episodes:"
    echo ""

    echo "$json" | jq -r --argjson limit "$limit" '.[:$limit][] | [.show.title, .episode.season, .episode.number, .episode.title, .watched_at] | @tsv' | \
    while IFS=$'\t' read -r show season number title watched_at; do
        printf -- "- %s S%02dE%02d — %s [%s]\n" "$show" "$season" "$number" "$title" "$watched_at"
    done
}

format_watched_shows() {
    local json="$1"

    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi

    echo "📺 Watched Shows (recent):"
    echo ""

    echo "$json" | jq -r '.[0:10][] | "- \(.show.title) (plays: \(.plays), last: \(.last_watched_at))"'
}

format_profile() {
    local json="$1"

    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi

    local name
    name=$(echo "$json" | jq -r '.name // .username')
    local username
    username=$(echo "$json" | jq -r '.username')
    local joined
    joined=$(echo "$json" | jq -r '.joined_at // ""')
    local url
    url=$(echo "$json" | jq -r '.url // ""')

    echo "🎬 Trakt Profile: ${name}"
    echo "@${username}"
    if [[ -n "$joined" ]]; then
        echo "📅 Joined: ${joined}"
    fi
    if [[ -n "$url" ]]; then
        echo "🔗 ${url}"
    fi
    return 0
}

format_stats() {
    local json="$1"

    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi

    local movies
    movies=$(echo "$json" | jq -r '.movies.watched // 0')
    local shows
    shows=$(echo "$json" | jq -r '.shows.watched // 0')
    local episodes
    episodes=$(echo "$json" | jq -r '.episodes.watched // 0')

    echo "🎬 Trakt Stats"
    echo "Movies watched: ${movies}"
    echo "Shows watched: ${shows}"
    echo "Episodes watched: ${episodes}"
}

main() {
    if [[ $# -lt 1 ]]; then
        print_usage
        exit 1
    fi

    local command="$1"
    shift

    case "$command" in
        watching)
            check_required_vars
            check_bins
            format_watching "$(api_get "/users/${TRAKT_USERNAME}/watching")"
            ;;
        recent)
            check_required_vars
            check_bins
            local limit="${1:-10}"
            validate_limit "$limit"
            format_recent "$(api_get "/users/${TRAKT_USERNAME}/history/episodes" "limit=${limit}")" "$limit"
            ;;
        watched-shows)
            check_required_vars
            check_bins
            format_watched_shows "$(api_get "/users/${TRAKT_USERNAME}/watched/shows")"
            ;;
        stats)
            check_required_vars
            check_bins
            format_stats "$(api_get "/users/${TRAKT_USERNAME}/stats")"
            ;;
        profile)
            check_required_vars
            check_bins
            format_profile "$(api_get "/users/${TRAKT_USERNAME}")"
            ;;
        playback)
            check_required_vars
            check_bins
            local type="${1:-}"
            local start_at="${2:-}"
            local end_at="${3:-}"
            if [[ -z "$type" || -z "$start_at" || -z "$end_at" ]]; then
                echo "Usage: trakt-api.sh playback <type> <start_at> <end_at>" >&2
                exit 1
            fi
            local params
            params="start_at=$(jq -nr --arg s "$start_at" '$s|@uri')&end_at=$(jq -nr --arg s "$end_at" '$s|@uri')"
            api_get_auth "/sync/playback/${type}" "$params" | jq
            ;;
        device-code)
            check_required_vars
            check_bins
            local body
            body=$(jq -nr --arg cid "$TRAKT_CLIENT_ID" '{client_id:$cid}')
            oauth_post "/oauth/device/code" "$body" | jq
            ;;
        device-token)
            check_required_vars
            check_bins
            local device_code="${1:-}"
            if [[ -z "$device_code" ]]; then
                echo "Usage: trakt-api.sh device-token <device_code>" >&2
                exit 1
            fi
            if [[ -z "${TRAKT_CLIENT_SECRET:-}" ]]; then
                echo "Error: TRAKT_CLIENT_SECRET not set (required for device token exchange)" >&2
                exit 1
            fi
            local body
            body=$(jq -nr --arg cid "$TRAKT_CLIENT_ID" --arg cs "$TRAKT_CLIENT_SECRET" --arg code "$device_code" '{code:$code, client_id:$cid, client_secret:$cs}')
            oauth_post "/oauth/device/token" "$body" | jq
            ;;
        help|--help|-h)
            print_usage
            ;;
        *)
            echo "Error: Unknown command '$command'" >&2
            echo "Run 'trakt-api.sh help' for usage" >&2
            exit 1
            ;;
    esac
}

main "$@"


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