Skill: cindulasai/clawshorts (version 1.3.2)

--- name: clawshorts description: Block YouTube Shorts on Fire TV. Use when asked to check, manage, or configure YouTube Shorts limiting on Buck's Fire TV devices. Triggers on requests like "check shorts quota", "reset shorts", "shorts status", "how much shorts watched today", "stop shorts limiter", "start shorts limiter". metadata: { "openclaw": { "requires": { "bins": ["adb", "python3"] } } } --- # ClawShorts YouTube Shorts limiter for Fire TV. Monitors watch time per device and auto-blocks when daily limit is reached. ## Invocation **Primary entry point:** ```bash ~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh <command> ``` ## Commands | Command | When to use | |---------|-------------| | `status` | Check today's usage, remaining quota, daemon health | | `reset [IP]` | Reset today's counter (all devices or specific IP) | | `start` | Start the daemon if not running | | `stop` | Stop the daemon | | `history [days]` | Show watch history (default 30 days) | | `logs [N]` | Show last N daemon log lines (default 50) | | `list` | List all configured devices with per-device config | | `setup <IP> [NAME]` | First-time setup for a new device | | `add <IP> [NAME]` | Add another Fire TV | | `connect <IP>` | Connect ADB to device + auto-detect screen | | `enable <IP>` / `disable <IP>` | Enable/disable a device | | `config [show\|get\|set\|reset]` | View/set global or per-device config | | `detect <IP>` | Re-detect screen resolution via ADB, update DB | ## Detection Logic Detection requires **both** conditions to be true simultaneously: 1. Player width < **30%** of screen width (configurable per-device) 2. Aspect ratio < **1.3** (portrait — distinguishes Shorts from 16:9 landscape previews) - Poll interval: 3 seconds via ADB - Shorts: ~32% screen width, 9:16 portrait (ar ~0.56) - Regular video: ~100% screen width, 16:9 landscape (ar ~1.78) - Home/browse: no video active - Only actual Shorts playback counts toward limit ## Configuration All detection parameters are stored in SQLite with global defaults and per-device overrides. **Global defaults** (`shorts config`): | key | default | description | |-----|---------|-------------| | `shorts_width_threshold` | 0.30 | player width must be < this ratio of screen width | | `shorts_max_aspect_ratio` | 1.3 | portrait if ar < this value | | `shorts_fallback_height_ratio` | 0.4 | fallback: player height must exceed this ratio of screen height | | `shorts_delta_cap` | 300 | max seconds accumulated per poll | | `default_screen_width` | 1920 | fallback assumed screen width | | `default_screen_height` | 1080 | fallback assumed screen height | **Per-device overrides** — any of the above can be set per-device in the `devices` table. NULL = use global default. **Config commands:** ```bash shorts config # show all global defaults shorts config get <key> # get a specific value shorts config set <key> <value> # set global default shorts config set <IP> <col> <value> # set per-device override shorts config reset <IP> # clear per-device overrides shorts detect <IP> # re-detect screen via ADB ``` ## Data Locations - Database: `~/.clawshorts/clawshorts.db` (SQLite) - `config` — global detection defaults - `devices` — per-device settings (IP, name, limit, screen size, thresholds) - `daily_usage` — daily watch time per device - Daemon log: `~/.clawshorts/daemon.log` - LaunchAgent: `~/Library/LaunchAgents/com.fink.clawshorts.plist` ## Requirements - `adb` (Android platform tools) - Python 3 - Fire TV with ADB debugging enabled - `shorts` symlink at `/opt/homebrew/bin/shorts` (optional) ## ⚠️ Security Notes **ADB has no built-in authentication.** Only enable ADB Debugging on a **trusted, password-protected home network**. Never on public WiFi. Anyone on the same network with ADB enabled can connect to your Fire TV. **This tool only accepts private IP addresses** (10.x.x.x, 172.16–31.x.x, 192.168.x.x). Public IPs are rejected to prevent accidental targeting of unrelated hosts.

path: README.md
content: # ClawShorts — Block YouTube Shorts on Fire TV

**Set a daily limit. When you hit it, YouTube closes automatically.**

No apps to install on your TV. No accounts needed. Just a daily time limit for YouTube Shorts, enforced on your Fire TV.

---

## What It Does

- Watches how long you watch YouTube Shorts each day
- Closes YouTube when your daily limit is reached
- Resets automatically at midnight
- Works on any Amazon Fire TV / Fire Stick

---

## What You Need

1. A Fire TV or Fire Stick on the same WiFi as your computer
2. Your computer (Mac/Linux) — OpenClaw installed
3. About 5 minutes to set it up

---

## Setup (Step by Step)

### Step 1 — Find Your Fire TV IP

1. On your Fire TV: **Settings → My Fire TV → About → Network**
2. Look for **IP Address** (like `192.168.1.100`)
3. Write it down

### Step 2 — Enable ADB Debugging

1. On your Fire TV: **Settings → My Fire TV → Developer Options**
2. Turn **ON ADB Debugging**

> ⚠️ **Security:** ADB lets your computer control your Fire TV. Only enable this on a **trusted home network**.

### Step 3 — Set It Up

On your computer, run:

```bash
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh setup 192.168.1.100
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh install
```

Replace `192.168.1.100` with your Fire TV's IP address from Step 1.

**That's it.** The daemon starts automatically and will enforce your limit.

---

## Choosing Your Daily Limit

By default, the limit is **300 seconds (5 minutes)** per day.

To change it:

```bash
# 10 minutes per day
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh install 600

# 30 minutes per day
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh install 1800
```

Or change per-device on the fly:

```bash
shorts config set 192.168.1.100 limit 900
```

---

## Commands

| What You Want | Command |
|---------------|---------|
| Check today's usage | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh status` |
| Reset today's counter | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh reset` |
| Stop the limiter | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh stop` |
| Start it again | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh start` |
| List devices | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh list` |
| View config | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh config` |
| Re-detect screen | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh detect 192.168.1.100` |
| Remove completely | `~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh uninstall` |

---

## Per-Device Configuration

Each TV can have its own detection settings and limits. All config is stored in SQLite.

**Show effective config for all devices:**
```bash
shorts list
```

**Show global defaults:**
```bash
shorts config
```

**Adjust detection threshold (example — 35% width):**
```bash
shorts config set shorts_width_threshold 0.35
```

**Set per-device override:**
```bash
shorts config set 192.168.1.100 width_threshold 0.40
```

**Reset device to use global defaults:**
```bash
shorts config reset 192.168.1.100
```

**Re-detect screen resolution:**
```bash
shorts detect 192.168.1.100
```

---

## How It Works

Every 3 seconds, ClawShorts checks what you're watching. A Shorts is detected when **both** conditions are true:

1. Player width < **35%** of screen width
2. Aspect ratio < **1.3** (portrait — distinguishes Shorts from 16:9 previews)

| What You're Watching | Width | Aspect Ratio | Counts? |
|---------------------|-------|-------------|---------|
| YouTube Shorts (vertical video) | ~32% | ~0.56 (portrait) | ✅ Yes |
| Regular YouTube video | ~100% | ~1.78 (landscape) | ❌ No |
| YouTube home / previews | ~38% | ~1.78 (landscape) | ❌ No |
| Fire TV home / other apps | — | — | ❌ No |

---

## FAQ

**Q: Does this work on Chromecast or smart TV apps?**
A: No — this uses ADB which only works with Fire TV / Android TV.

**Q: Can I use both Fire TV and Fire Stick?**
A: Yes — add both IPs: `shorts add 192.168.1.101 bedroom-tv`

**Q: Does it block regular YouTube videos?**
A: No — only YouTube Shorts. Regular videos don't count.

**Q: What happens at midnight?**
A: The counter resets automatically. You get your full daily limit back.

**Q: Can I adjust how sensitive the detection is?**
A: Yes — use `shorts config set shorts_width_threshold 0.40` to make it trigger on wider players (40% instead of 35%).

---

## Troubleshooting

**"ADB not found"** — Install ADB on your computer:
```bash
brew install android-platform-tools  # Mac
sudo apt install adb                # Linux
```

**"Daemon not running"** — Start it:
```bash
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh start
```

**Check if it's working:**
```bash
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh status
```

**View logs:**
```bash
tail ~/.clawshorts/daemon.log
```

---

## Uninstall

```bash
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh uninstall
```

This removes the auto-start daemon but keeps your usage data.

---

## Files & Data

- `~/.clawshorts/` — all data lives here
- `~/.clawshorts/clawshorts.db` — watch history and config (SQLite)
- `~/.clawshorts/daemon.log` — debug log
- `~/Library/LaunchAgents/com.fink.clawshorts.plist` — auto-start (macOS)

---

ClawShorts © 2026 — MIT License


path: SECURITY_AUDIT.md
content: # ClawShorts — Security Audit Report

**Audited by:** cisco/software-security tile + manual review
**Date:** 2026-04-01
**Version:** 1.3.0
**Files audited:** SKILL.md, scripts/clawshorts.sh, scripts/clawshorts-daemon.py, scripts/clawshorts_db.py, src/clawshorts/cli.py, src/clawshorts/validators.py, src/clawshorts/detection.py

---

## Severity Scale

| Rating | Meaning |
|--------|---------|
| 🔴 CRITICAL | Remote code execution, credential exposure, data destruction |
| 🟠 HIGH | Significant exploit path, network-level attack |
| 🟡 MEDIUM | Limited impact, requires local access or user cooperation |
| 🟢 LOW | Hardening opportunity, defense-in-depth |

---

## Findings

### 🟡 MEDIUM — No restriction to private IP ranges

**File:** `src/clawshorts/validators.py`

`validate_ipv4()` only checks that each octet is 0–255. It does **not** verify the IP is in a private range (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16). A user could accidentally add a public IP and the system would attempt ADB connections to it.

**Exploit scenario:** User mistypes IP as `192.168.1.999` → rejected by octet check. But `192.168.1.1` (a router admin panel) would be accepted and the daemon would attempt ADB connections to it.

**Impact:** Limited — ADB connections to unintended local devices. No remote exploit.

**Recommendation:** Add private IP validation:

```python
import ipaddress

def validate_ipv4(ip: str) -> bool:
    if not isinstance(ip, str):
        return False
    try:
        addr = ipaddress.ip_address(ip)
        return addr.is_private or addr.is_loopback
    except ValueError:
        return False
```

---

### 🟡 MEDIUM — ADB has no authentication (inherent to ADB, documented)

**File:** `clawshorts.sh` (install instructions)

ADB debugging on Fire TV has **no built-in authentication**. Anyone on the same network who enables ADB debugging can connect to the device without a password.

**This is an ADB design limitation, not a code defect.** The install instructions warn users to only enable ADB on trusted home networks.

**Recommendation (documentation):** Add a prominent warning in SKILL.md:
> ⚠️ **Security:** ADB has no authentication. Only enable ADB Debugging on a trusted, password-protected home network. Never on public WiFi.

---

### 🟡 MEDIUM — No confirmation before force-stopping YouTube

**File:** `scripts/clawshorts-daemon.py` (`_force_stop_youtube`)

When the daily limit is reached, YouTube is force-stopped with `am force-stop` with no user notification beyond a log line. The user who configured the device implicitly consented, but there's no:

- OS notification to the user before stopping
- Configurable grace period
- Dry-run / audit mode

**Impact:** Abrupt app termination with no warning on-screen.

**Recommendation:** Add a configurable grace/warning period (e.g., show a 30-second warning notification on the TV before stopping). This would require a Fire TV notification API, which ADB can send via `am start -a android.intent.action.WARNING`.

---

### 🟢 LOW — `cmd_stop` uses broad `pkill -f` pattern

**File:** `scripts/clawshorts.sh` (`cmd_stop`)

```bash
pids=$(pgrep -f "clawshorts-daemon" 2>/dev/null || true)
```

`pgrep -f` matches against the full command line. A process whose working directory or args contain the string "clawshorts-daemon" could be matched. Unlikely to cause issues in practice.

**Recommendation:** Use a more specific PID file approach:

```bash
echo "$pids" > "$STATE_DIR/daemon.pid"
# Stop: kill $(cat "$STATE_DIR/daemon.pid")
```

---

### 🟢 LOW — `cmd_stop` sends SIGKILL after 2s without checking if graceful shutdown succeeded

**File:** `scripts/clawshorts.sh` (`cmd_stop`)

```bash
kill -TERM $pids 2>/dev/null || true
sleep 2
pkill -f "clawshorts-daemon" 2>/dev/null || true
```

Always sends SIGKILL after 2 seconds regardless of whether the process exited cleanly. Minor — the daemon handles SIGTERM gracefully, but there's no check for actual exit.

**Recommendation:** Check if process exited before SIGKILL:

```bash
kill -TERM $pids 2>/dev/null || true
sleep 2
for pid in $pids; do
    kill -0 $pid 2>/dev/null && kill -9 $pid
done
```

---

### 🟢 LOW — No automatic cleanup of stale UI XML files

**File:** `scripts/clawshorts-daemon.py` (`_dump_ui`)

Temporary UI XML files are downloaded to `~/.clawshorts/ui-*.xml` and read back, but never deleted. Over many days of polling, these accumulate.

**Impact:** Low — one file per IP per daemon session, rarely restarted.

**Recommendation:** Add cleanup on daemon startup or on file read failure.

---

### 🟡 MEDIUM — Daemon runs as user with no resource limits

**File:** `clawshorts-daemon.sh`, LaunchAgent plist

The daemon has no CPU, memory, or file descriptor limits set in the LaunchAgent plist. A bug causing an infinite loop could consume resources.

**Impact:** Denial of service on the host machine.

**Recommendation:** Add to plist:

```xml
<key>HardResourceLimits</key>
<dict>
    <key>CPULimit</key>
    <integer>10</integer>
</dict>
```

---

## ✅ Security Strengths

| Area | Status |
|------|--------|
| Path traversal in UI dump | ✅ Protected — validates resolved path stays within STATE_DIR |
| SQL injection | ✅ Parameterized queries throughout |
| Command injection in ADB calls | ✅ Commands passed as list args, not shell strings |
| Credential exposure | ✅ No credentials stored; no secrets in code |
| Signal handling | ✅ Clean SIGINT/SIGTERM handlers |
| File permissions on DB | ✅ `os.chmod(DB_PATH, 0o600)` restricts SQLite file |
| Log rotation | ✅ RotatingFileHandler with 1MB cap + 3 backups |
| Input validation on IP | ✅ IPv4 format validated before ADB calls |
| LaunchAgent plist | ✅ User-level only, no system-wide modifications |
| No remote fetch | ✅ No `curl`/`wget` to external URLs in daemon |

---

## Summary

| Severity | Count |
|----------|-------|
| 🔴 CRITICAL | 0 |
| 🟠 HIGH | 0 |
| 🟡 MEDIUM | 4 |
| 🟢 LOW | 3 |

**Overall:** The codebase is well-structured with security fundamentals in place — no injection vulnerabilities, no credential leaks, no path traversal. The main risks are **configuration/environment** rather than code defects: ADB's inherent lack of auth, and the ability to point the tool at unintended IPs on the local network.

**Top 3 fixes to implement:**
1. Add private IP validation to `validate_ipv4()`
2. Add explicit ADB network trust warning to SKILL.md
3. Add a grace-period notification before force-stopping YouTube


path: SPEC.md
content: # ClawShorts — Per-Device Config & Auto-Detection Spec

## Goal

Replace hardcoded detection constants with a database-backed, per-device configurable system. Screen resolution is auto-detected via ADB on first setup, and detection thresholds are stored in SQLite with global defaults and per-device overrides.

---

## 1. Database Schema

### 1.1 `config` table — global system defaults

```sql
CREATE TABLE IF NOT EXISTS config (
    key          TEXT PRIMARY KEY,
    value        REAL NOT NULL,
    description  TEXT,
    updated_at   TEXT DEFAULT (datetime('now'))
);
```

**Initial rows (inserted on first run / migration):**

| key | value | description |
|-----|-------|-------------|
| `shorts_width_threshold` | 0.30 | player width must be < this ratio of screen width |
| `shorts_max_aspect_ratio` | 1.3 | portrait aspect ratio cap; ar < this = portrait |
| `shorts_fallback_height_ratio` | 0.4 | fallback: player height must exceed this ratio of screen height |
| `shorts_delta_cap` | 300 | max seconds accumulated per poll (prevents clock jumps) |
| `default_screen_width` | 1920 | fallback assumed screen width (used if auto-detect fails) |
| `default_screen_height` | 1080 | fallback assumed screen height |

### 1.2 `devices` table — add new columns

All 6 detection parameters are per-device. Every column is nullable — NULL means "use the global default from the `config` table."

```sql
ALTER TABLE devices ADD COLUMN screen_width              INTEGER;  -- pixels, auto-detected or manually set
ALTER TABLE devices ADD COLUMN screen_height             INTEGER;  -- pixels, auto-detected or manually set
ALTER TABLE devices ADD COLUMN width_threshold          REAL;    -- e.g. 0.30; NULL = global default
ALTER TABLE devices ADD COLUMN max_aspect_ratio         REAL;    -- e.g. 1.3; NULL = global default
ALTER TABLE devices ADD COLUMN fallback_height_ratio    REAL;    -- e.g. 0.4; NULL = global default
ALTER TABLE devices ADD COLUMN delta_cap               REAL;    -- e.g. 300; NULL = global default
ALTER TABLE devices ADD COLUMN updated_at TEXT DEFAULT (datetime('now'));
```

### 1.3 Full per-device config keyspace

Each of these 6 parameters can be set per-device (in `devices` table) or globally (in `config` table):

| key | default | per-device column | description |
|-----|---------|------------------|-------------|
| `shorts_width_threshold` | 0.30 | `width_threshold` | player width must be < this ratio of screen width |
| `shorts_max_aspect_ratio` | 1.3 | `max_aspect_ratio` | portrait if ar < this value |
| `shorts_fallback_height_ratio` | 0.4 | `fallback_height_ratio` | fallback: player height must exceed this ratio of screen height |
| `shorts_delta_cap` | 300 | `delta_cap` | max seconds accumulated per poll (prevents clock jumps) |
| `default_screen_width` | 1920 | `screen_width` | screen width in pixels |
| `default_screen_height` | 1080 | `screen_height` | screen height in pixels |

### 1.4 Schema migration function

- If `config` table doesn't exist, create it and seed with defaults
- If `devices` columns don't exist, add them with `NULL` defaults
- Existing devices keep `NULL` for all new columns → fall back to global defaults
- This makes the migration zero-downtime for existing installations

---

## 2. Screen Auto-Detection (ADB)

### 2.1 Detection command

```python
def detect_screen_resolution(ip: str) -> tuple[int, int] | None:
    """Query screen size via ADB. Returns (width, height) or None on failure."""
    result = subprocess.run(
        ["adb", "-s", ip, "shell", "dumpsys", "display"],
        capture_output=True, text=True, timeout=5
    )
    # Parse mDisplayWidth / mDisplayHeight from output
    # Fallback: use global default_screen_width/height from config
```

### 2.2 When auto-detect runs

| Event | Action |
|-------|--------|
| `shorts setup <IP>` (first time) | Detect screen resolution, store in `devices.screen_width/height` |
| `shorts add <IP>` | Detect and store |
| `shorts connect <IP>` | Re-detect and update stored values |
| Auto-detect fails | Use `default_screen_width/height` from `config` table |

---

## 3. Config Resolution (Priority Order)

For any device, values are resolved as:

```
per-device column (if not NULL)
    ?? global config key (from config table)
        ?? hardcoded Python default (last resort, for migration safety)
```

### Example

| Device | `width_threshold` col | Resolution |
|--------|----------------------|------------|
| loft (192.168.1.239) | NULL | global default: 0.30 |
| sofa (192.168.1.202) | 0.35 | per-device: 0.35 |

All 6 keys follow the same per-device / global precedence. If a per-device column is NULL, the global `config` table value is used.

---

## 4. Daemon Changes (`clawshorts-daemon.py`)

### 4.1 Load config at startup

```python
def _load_global_config() -> dict[str, float]:
    """Load all config keys from SQLite into a dict."""
    rows = db.get_all_config()
    return {key: value for key, value in rows}

def _get_device_config(ip: str, global_cfg: dict) -> dict:
    """Merge per-device overrides onto global config."""
    dev = db.get_device(ip)
    return {
        key: getattr(dev, key, None) or global_cfg.get(key)
        for key in ["screen_width", "screen_height", "width_threshold",
                    "max_aspect_ratio", "fallback_height_ratio", "delta_cap"]
    }
```

### 4.2 Per-device state

The per-device state dict (line ~78) currently has `screen_w` / `screen_h` as cached scalars. These should be loaded from device config and refreshed on reconnect.

```python
# Before: screen_w/screen_h were hardcoded 1920/1080
# After: loaded from db, or global config defaults
state.screen_w = device_cfg.get("screen_width", 1920)
state.screen_h = device_cfg.get("screen_height", 1080)
state.width_threshold   = device_cfg.get("width_threshold", 0.30)
state.max_aspect_ratio = device_cfg.get("max_aspect_ratio", 1.3)
state.fallback_height_ratio = device_cfg.get("fallback_height_ratio", 0.4)
state.delta_cap = device_cfg.get("delta_cap", 300)
```

### 4.3 Live config reload (optional, future)

Add a SIGHUP handler or a `shorts reload-config` command that re-reads from DB without restarting the daemon.

---

## 5. CLI Changes

### 5.1 New commands

```bash
shorts config                    # show global defaults
shorts config get <key>          # get a specific global config value
shorts config set <key> <value>  # set a global config value
shorts config show [IP]          # show all config keys (global defaults); or per-device effective values if IP given
shorts config set <IP> <key> <value>  # set per-device override
shorts config reset <IP>         # clear per-device overrides → use globals
shorts detect <IP>               # re-detect screen resolution via ADB, update DB
```

### 5.2 `shorts list` output (update)

```
  loft
     IP: 192.168.1.239 | Limit: 600/day
     Screen: 1920x1080 | w=0.30 (global) | ar=1.3 (global) | h=0.4 (global) | cap=300 (global)

  sofa
     IP: 192.168.1.202 | Limit: 300/day
     Screen: 1920x1080 | w=0.35 (per-device) | ar=1.3 (global) | h=0.4 (global) | cap=300 (global)
```

### 5.3 `shorts setup` / `shorts add` (update)

Both commands should:
1. Auto-detect screen resolution via ADB
2. Store in `devices.screen_width/height`
3. Use global defaults for threshold columns (NULL → fallback to global)

---

## 6. Files to Modify

| File | Changes |
|------|---------|
| `scripts/clawshorts_db.py` | Add `get_config`, `set_config`, `get_device_config`, `update_device_screen`, `update_device_threshold` functions |
| `scripts/clawshorts-daemon.py` | Replace hardcoded constants with DB-loaded values; add `_load_global_config()` |
| `src/clawshorts/cli.py` | Add new `config` subcommand with get/set/show/reset; add `detect` command; update `list` output |
| `scripts/clawshorts.sh` | Route new `config` and `detect` commands |
| `src/clawshorts/constants.py` | Keep only truly universal constants (e.g. poll interval range limits); remove detection thresholds |

---

## 7. Migration Plan

### Phase 1: Schema migration
- Add `config` table if not exists; seed with defaults
- Add columns to `devices` if not exist
- Existing devices: columns are NULL → use global defaults
- **Risk:** Low — only additive schema changes

### Phase 2: Config loading
- Modify daemon to load global config at startup
- Falls back to hardcoded defaults if DB read fails
- Existing devices with NULL columns still work exactly as before

### Phase 3: Per-device overrides
- Add per-device column writes/reads
- Implement config resolution priority
- Update `shorts list` display

### Phase 4: CLI commands
- Implement `shorts config` and `shorts detect`

### Phase 5: Cleanup
- Remove threshold constants from `constants.py`
- Keep only non-detection constants (poll interval bounds, etc.)

---

## 8. Backward Compatibility

- **Existing installations:** Migration adds new columns with NULL → existing behavior unchanged
- **New installs:** Schema created with all columns from day one
- **Config file (`devices.yaml`):** Already migrated to SQLite; no yaml code remaining
- **LaunchAgent restart:** Required to pick up new daemon binary

---

## 9. Testing Checklist

- [ ] Fresh install on new device → screen auto-detected and stored
- [ ] Existing device shows NULL thresholds → uses global defaults
- [ ] `shorts config set global shorts_width_threshold 0.25` → all devices use new threshold
- [ ] `shorts config set <IP> shorts_width_threshold 0.35` → only that device uses 0.35
- [ ] `shorts config reset <IP>` → device falls back to global
- [ ] `shorts detect <IP>` → re-runs ADB detection and updates DB
- [ ] Daemon restart → picks up latest DB config (not stale in-memory)
- [ ] `shorts list` → shows effective config per device
- [ ] Offline ADB → uses `default_screen_width/height` from config table


path: TROUBLESHOOTING.md
content: # ClawShorts Troubleshooting Guide

## Common Issues

### Daemon Keeps Dying / Restarting Repeatedly

**Symptom:** The ClawShorts daemon restarts every 30-90 seconds, cycling continuously. Logs show "Shutdown requested (signal 15)" followed by immediate restart.

**Root Cause:** The LaunchAgent plist uses `ThrottleInterval` inside `KeepAlive`, which conflicts with launchd's restart behavior.

**Bad plist configuration:**
```xml
<key>KeepAlive</key>
<dict>
    <key>ThrottleInterval</key>
    <integer>60</integer>
</dict>
```

`ThrottleInterval` tells launchd "wait 60 seconds before restarting if this job dies." But combined with `KeepAlive`, this creates a restart cycle where launchd repeatedly kills and restarts the job with no useful polling happening between restarts.

**Fix:** Use `KeepAlive: true` without ThrottleInterval:
```xml
<key>KeepAlive</key>
<true/>
```

This tells launchd "keep this running indefinitely, restart immediately if it dies." No throttle logic.

**Verification:**
```bash
# Check if daemon is cycling
tail -f ~/.clawshorts/daemon.log

# Look for repeated "Shutdown requested (signal 15)" + "ClawShorts started" cycles

# Check daemon uptime (should be stable, not restarting every minute)
ps aux | grep clawshorts-daemon
ps -p <PID> -o etime  # elapsed time since start
```

**Workaround if launchctl remains unstable:** Run the daemon manually with nohup:
```bash
nohup python3 /path/to/clawshorts-daemon.py 192.168.1.100 --daily-limit 300 --debug > ~/.clawshorts/daemon.log 2>&1 &
```
The nohup approach bypasses launchd entirely and provides rock-solid stability.

---

### Time Not Accumulating / Shorts Not Detected

**Symptom:** You're watching YouTube Shorts but the counter stays at 0 or doesn't increase.

**How Detection Works:**

The daemon checks the UI every 3 seconds via ADB:
1. Dumps the UI hierarchy with `uiautomator dump /sdcard/ui.xml`
2. Finds the **focused element** (the active video player)
3. Measures its pixel width
4. Compares to screen width (1920x1080 on Fire TV):

| Focused Width | Interpretation | Counts Time? |
|--------------|----------------|--------------|
| < 1728px (< 90%) | YouTube Shorts (portrait 9:16, centered) | ✅ YES |
| >= 1728px (>= 90%) | Regular YouTube video (full screen landscape) | ❌ NO |
| Not YouTube app | Fire TV Home / other app | ❌ NO |

**Debugging Steps:**
```bash
# Manually check what's on screen
adb -s 192.168.1.100 shell "uiautomator dump /sdcard/ui.xml"
adb -s 192.168.1.100 pull /sdcard/ui.xml /tmp/ui.xml
grep -o 'focused="true"[^>]*bounds="[^"]*"' /tmp/ui.xml

# Check what app is in foreground
adb -s 192.168.1.100 shell "dumpsys activity activities | grep -E mResumedActivity"

# Verify YouTube is the active app
adb -s 192.168.1.100 shell "dumpsys activity activities | grep -i youtube"
```

**Why time might not be accumulating:**
1. You're on the Fire TV Home screen, not YouTube
2. You're watching regular YouTube videos (full screen), not Shorts
3. YouTube app is open but nothing is playing
4. You've already hit your daily limit (300s by default)

**Verify the database:**
```bash
sqlite3 ~/.clawshorts/clawshorts.db "SELECT * FROM daily_usage WHERE date = date('now', 'localtime');"
```
If this shows 300+ seconds, you've hit your limit.

---

### ADB Device Not Found / Connection Lost

**Symptom:** `adb devices` shows nothing, or Fire TV disconnects repeatedly.

**Solutions:**
```bash
# Reconnect
adb kill-server && adb start-server
adb connect 192.168.1.100:5555

# Verify connection
adb devices
```

The daemon has built-in auto-reconnect: if ADB connection drops, it automatically tries to reconnect on the next poll cycle.

---

### Daily Limit Not Resetting

The limit resets at midnight local time automatically. The daemon detects day changes and resets the counter.

If you need to reset manually:
```bash
sqlite3 ~/.clawshorts/clawshorts.db "UPDATE daily_usage SET seconds = 0 WHERE date = date('now', 'localtime');"
```

---

## Verifying Installation

After installing, verify everything works:

```bash
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh status
```

This does a **live verification** showing:
- Daemon status (🟢 active / 🔴 not running / 🟡 idle)
- Current screen info (app, focused element width, Shorts vs regular)
- Today's usage vs limit

If daemon shows 🔴, run:
```bash
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh start
~/.openclaw/workspace/skills/clawshorts/scripts/clawshorts.sh status  # verify again
```

## Debug Mode

Enable debug logging to see every UI poll:
```bash
# Run with --debug flag
nohup python3 /path/to/clawshorts-daemon.py 192.168.1.100 --daily-limit 300 --debug > ~/.clawshorts/daemon.log 2>&1 &
```

Debug output shows:
- Screen size detection
- Focused element width on each poll
- Delta seconds added to the counter
- Whether Shorts or regular video is detected

---

## LaunchAgent Plist Reference

**Location:** `~/Library/LaunchAgents/com.fink.clawshorts.plist`

**Working configuration:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.fink.clawshorts</string>
    <key>ProgramArguments</key>
    <array>
        <string>python3</string>
        <string>/Users/username/.openclaw/workspace/skills/clawshorts/scripts/clawshorts-daemon.py</string>
        <string>192.168.1.100</string>
        <string>--daily-limit</string>
        <string>300</string>
        <string>--debug</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/username/.clawshorts.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/username/.clawshorts.log</string>
</dict>
</plist>
```

---

## File Locations

| File | Purpose |
|------|---------|
| `~/.clawshorts/clawshorts.db` | SQLite database with daily usage |
| `~/.clawshorts/daemon.log` | Debug log from the daemon |
| `~/.clawshorts.log` | LaunchAgent stdout/stderr (if using plist) |
| `~/.clawshorts/ui-<IP>.xml` | Last UI hierarchy dump |
| `~/Library/LaunchAgents/com.fink.clawshorts.plist` | macOS LaunchAgent plist |

---

## Database Schema

```sql
CREATE TABLE daily_usage (
    ip      TEXT NOT NULL,
    date    TEXT NOT NULL,
    seconds REAL NOT NULL DEFAULT 0.0,
    PRIMARY KEY (ip, date)
);
```

Query today's usage:
```bash
sqlite3 ~/.clawshorts/clawshorts.db "SELECT * FROM daily_usage WHERE date = date('now', 'localtime');"
```


path: pyproject.toml
content: # Fire TV Shorts Limiter - Python Project
# Built with Tessl spec-driven development

[project]
name = "clawshorts"
version = "0.4.0"
description = "Block YouTube Shorts on Fire TV with daily limits."
requires-python = ">=3.11"
dependencies = [
    "pyyaml>=6.0",
    "pydantic>=2.0",
]

[dependency-groups]
dev = [
    "ruff",
    "ty",
    "pytest",
    "pytest-cov",
    "pre-commit",
]
lint = ["ruff", "ty"]
test = ["pytest", "pytest-cov"]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["ALL"]
ignore = ["D", "COM812", "ISC001", "E501"]

[tool.pytest]
testpaths = ["tests"]
addopts = ["--cov=src", "--cov-fail-under=0"]
pythonpath = ["src"]

[tool.ty]
terminal = { error-on-warning = true }
environment = { python-version = "3.11" }

[tool.pre-commit]
skip = []


path: scripts/clawshorts-daemon.py
content: """ClawShorts — Fire TV YouTube Shorts time limiter daemon.

Monitors Fire TV devices via ADB. When YouTube Shorts is detected
(video player width < 90pc of screen width), watch time is accumulated.
Once the daily limit (default 120 s = 2 min) is reached, YouTube is
force-stopped. The limit resets automatically at midnight.

Usage:
    python3 clawshorts-daemon.py 192.168.1.100
    python3 clawshorts-daemon.py 192.168.1.100 --debug
    python3 clawshorts-daemon.py 192.168.1.100,192.168.1.101 --daily-limit 120
"""
from __future__ import annotations

import argparse
import logging
import logging.handlers
import re
import signal
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Final

# Add src/ to path for shared modules
_SCRIPTS_DIR = Path(__file__).resolve().parent
_SRC_DIR = _SCRIPTS_DIR.parent / "src"
sys.path.insert(0, str(_SCRIPTS_DIR))  # for clawshorts_db
sys.path.insert(0, str(_SRC_DIR))        # for clawshorts packages

import clawshorts_db as db
from clawshorts.constants import (
    MAX_DELTA_SECONDS as _FALLBACK_MAX_DELTA,
    STATE_DIR,
)
from clawshorts.validators import validate_ipv4

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

LOG_FILE = STATE_DIR / "daemon.log"
ADB_PORT: Final = 5555
ADB_TIMEOUT: Final = 8
HEARTBEAT_INTERVAL: Final = 30.0  # seconds between heartbeat log lines

YOUTUBE_PACKAGES = (
    "com.google.android.youtube.tv",
    "com.amazon.firetv.youtube",
    "com.amazon.youtube.tv",
    "com.google.android.youtube",
    "com.youtube.tv",
)


# ---------------------------------------------------------------------------
# Config — loaded from DB at startup
# ---------------------------------------------------------------------------

def _load_devices_from_db() -> list[dict]:
    """Load full device dicts (with per-device config columns) from SQLite."""
    db.init_db()
    devs = db.get_devices()
    return [d for d in devs if d.get("enabled", True)]


def _resolve_config(device: dict, global_cfg: dict) -> dict:
    """Resolve all 6 config keys for a device: per-device column → global → hardcoded default."""
    def resolve(key: str, col: str | None, default: float) -> float:
        val = device.get(col) if col else None
        if val is not None:
            return float(val)
        return float(global_cfg.get(key, default))

    # Map config keys to device columns (None = not per-device overridable → use global only)
    KEY_COL_DEFAULT = [
        ("shorts_width_threshold", "width_threshold", 0.30),
        ("shorts_max_aspect_ratio", "max_aspect_ratio", 1.3),
        ("shorts_fallback_height_ratio", "fallback_height_ratio", 0.4),
        ("shorts_delta_cap", "delta_cap", 300.0),
        ("default_screen_width", "screen_width", 1920.0),
        ("default_screen_height", "screen_height", 1080.0),
    ]

    result = {}
    for key, col, default in KEY_COL_DEFAULT:
        result[key] = resolve(key, col, default)

    return result


# ---------------------------------------------------------------------------
# Per-device in-memory state
# ---------------------------------------------------------------------------

@dataclass
class DeviceState:
    """Mutable state for one device within a DaemonContext."""

    last_short_ts: float = 0.0  # monotonic ts of last poll that detected Shorts
    screen_w: int = 0           # cached screen width (pixels)
    screen_h: int = 0           # cached screen height (pixels)
    date: str = ""             # current tracking date (midnight rollover)
    # Detection thresholds (resolved: per-device → global → hardcoded default)
    width_threshold: float = 0.30
    max_aspect_ratio: float = 1.3
    fallback_height_ratio: float = 0.4
    delta_cap: float = 300.0


# ---------------------------------------------------------------------------
# DaemonContext — wraps all mutable globals
# ---------------------------------------------------------------------------

class DaemonContext:
    """Holds all mutable daemon state. Passed explicitly; no module-level _vars."""

    def __init__(
        self,
        devices: list[dict],
        global_cfg: dict[str, float],
        interval: int,
    ) -> None:
        # devices: full device dicts from DB
        # global_cfg: global config key→value from config table
        self.devices = devices  # list[dict]
        self.global_cfg = global_cfg
        self.interval = interval
        self.states: dict[str, DeviceState] = {}
        for dev in devices:
            ip = dev["ip"]
            cfg = _resolve_config(dev, global_cfg)
            state = DeviceState(
                screen_w=int(cfg["default_screen_width"]),
                screen_h=int(cfg["default_screen_height"]),
                width_threshold=cfg["shorts_width_threshold"],
                max_aspect_ratio=cfg["shorts_max_aspect_ratio"],
                fallback_height_ratio=cfg["shorts_fallback_height_ratio"],
                delta_cap=cfg["shorts_delta_cap"],
            )
            self.states[ip] = state
        self.shutdown: bool = False
        self.last_heartbeat: float = time.time()

    def request_shutdown(self) -> None:
        self.shutdown = True


# ---------------------------------------------------------------------------
# Logging setup
# ---------------------------------------------------------------------------

def _setup_logging(debug: bool) -> None:
    global log
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    fmt = logging.Formatter(
        "[%(asctime)s] %(levelname)-7s %(message)s", "%Y-%m-%d %H:%M:%S"
    )
    fh = logging.handlers.RotatingFileHandler(
        LOG_FILE, maxBytes=1_048_576, backupCount=3, encoding="utf-8",
    )
    fh.setLevel(logging.DEBUG)
    fh.setFormatter(fmt)
    ch = logging.StreamHandler(sys.stdout)
    ch.setLevel(logging.DEBUG if debug else logging.INFO)
    ch.setFormatter(fmt)
    log = logging.getLogger("clawshorts")
    log.setLevel(logging.DEBUG)
    log.addHandler(fh)
    log.addHandler(ch)


# ---------------------------------------------------------------------------
# Signal handling
# ---------------------------------------------------------------------------

def _make_signal_handler(ctx: DaemonContext):
    def _handler(signum: int, _frame: object) -> None:
        log.info("Shutdown requested (signal %d)", signum)
        ctx.request_shutdown()
    return _handler


# ---------------------------------------------------------------------------
# ADB helpers
# ---------------------------------------------------------------------------

def _dev(ip: str) -> str:
    """Return device address with port."""
    return f"{ip}:{ADB_PORT}"


def _shell(ip: str, cmd: str, timeout: int = ADB_TIMEOUT) -> str:
    """Execute an ADB shell command on the device. Returns stdout or empty str."""
    try:
        r = subprocess.run(
            ["adb", "-s", _dev(ip), "shell", cmd],
            capture_output=True,
            text=True,
            timeout=timeout,
        )
        return r.stdout
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return ""


def _ensure_connected(ip: str) -> bool:
    """Ensure ADB is connected. Connect if needed. Returns True on success."""
    try:
        r = subprocess.run(
            ["adb", "devices"], capture_output=True, text=True, timeout=ADB_TIMEOUT,
        )
        if f"{_dev(ip)}\tdevice" in r.stdout:
            return True
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return False

    try:
        r = subprocess.run(
            ["adb", "connect", _dev(ip)], capture_output=True, text=True, timeout=15,
        )
        ok = "connected" in r.stdout.lower()
        if ok:
            log.info("Connected to %s", ip)
        else:
            log.warning("Cannot connect to %s: %s", ip, r.stdout.strip())
        return ok
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return False


# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------

def _is_youtube_active(ip: str) -> bool:
    """Return True only if a YouTube package is the foreground (resumed) activity."""
    # Check mResumedActivity — this is the actual foreground activity on Fire OS
    out = _shell(
        ip,
        "dumpsys activity activities | grep -m1 'mResumedActivity'",
        timeout=10,
    )
    # Only count if a YouTube package is in the RESUMED activity (foreground)
    # This prevents false positives when YouTube is backgrounded but still in memory
    return any(pkg in out for pkg in YOUTUBE_PACKAGES)


def _get_screen_size(state: DeviceState, ip: str) -> tuple[int, int]:
    """Return (width, height) in pixels, cached after first successful query."""
    if state.screen_w and state.screen_h:
        return state.screen_w, state.screen_h
    out = _shell(ip, "wm size", timeout=6)
    m = re.search(r"(\d+)x(\d+)", out)
    if m:
        state.screen_w, state.screen_h = int(m.group(1)), int(m.group(2))
        log.debug("%s — screen: %dx%d", ip, state.screen_w, state.screen_h)
    return state.screen_w or 1920, state.screen_h or 1080


def _dump_ui(ip: str) -> str:
    """Dump UI hierarchy XML from the device. Returns empty string on failure."""
    slug = ip.replace('.', '-')
    tmp = STATE_DIR / f"ui-{slug}.xml"

    # Security: resolve path and ensure it's within STATE_DIR
    resolved = tmp.resolve()
    if not str(resolved).startswith(str(STATE_DIR.resolve())):
        log.error("Path traversal attempt detected: %s", ip)
        return ""

    try:
        _shell(ip, "uiautomator dump /sdcard/ui.xml", timeout=15)
        r = subprocess.run(
            ["adb", "-s", _dev(ip), "pull", "/sdcard/ui.xml", str(tmp)],
            capture_output=True,
            text=True,
            timeout=10,
        )
        return tmp.read_text(errors="replace") if r.returncode == 0 else ""
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return ""


def _is_shorts(state: DeviceState, ip: str, xml: str) -> bool:
    """Detect YouTube Shorts using dual criteria:
      1. Width < width_threshold fraction of screen width
      2. Aspect ratio < max_aspect_ratio (portrait-ish, distinguishes from 16:9 hover previews)

    YouTube home hover previews are 16:9 landscape (~1.78 aspect).
    Actual Shorts are 9:16 portrait (<1.0 aspect).
    Both can appear as ~38% width — aspect ratio differentiates them.

    Strategy:
      1. Focused element (Fire TV YouTube puts focus on the video)
      2. Named player / surface / video elements
      3. Last fallback: centred elements filling significant height
    """
    screen_w, screen_h = _get_screen_size(state, ip)
    strict_threshold = screen_w * state.width_threshold

    # Primary: focused element
    for m in re.finditer(
        r'focused="true"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml
    ):
        x1, y1, x2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
        player_w = x2 - x1
        player_h = y2 - y1
        if player_w < 100:
            continue
        ratio = player_w / screen_w
        aspect = player_w / player_h if player_h > 0 else 999
        log.debug("%s — focused %dx%dpx (%.0f%%, ar=%.2f)", ip, player_w, player_h, ratio * 100, aspect)
        # Must pass BOTH: width strict threshold AND portrait aspect
        return player_w < strict_threshold and aspect < state.max_aspect_ratio

    # Secondary: named player / surface / video elements
    for m in re.finditer(
        r'resource-id="[^"]*(?:player|surface|video)[^"]*"[^>]*'
        r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"',
        xml, re.I,
    ):
        x1, y1, x2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
        player_w = x2 - x1
        player_h = y2 - y1
        if player_w < 100:
            continue
        ratio = player_w / screen_w
        aspect = player_w / player_h if player_h > 0 else 999
        log.debug("%s — player %dx%dpx (%.0f%%, ar=%.2f)", ip, player_w, player_h, ratio * 100, aspect)
        return player_w < strict_threshold and aspect < state.max_aspect_ratio

    # Fallback: centred elements that fill significant height
    best_w, best_h = 0, 0
    for m in re.finditer(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml):
        x1, y1, x2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
        w, h = x2 - x1, y2 - y1
        if h > screen_h * state.fallback_height_ratio and x1 > 0 and x2 < screen_w and w > best_w:
            best_w, best_h = w, h

    if best_w == 0:
        return False

    ratio = best_w / screen_w
    aspect = best_w / best_h if best_h > 0 else 999
    log.debug("%s — fallback %dx%dpx (%.0f%%, ar=%.2f)", ip, best_w, best_h, ratio * 100, aspect)
    return best_w < strict_threshold and aspect < state.max_aspect_ratio


# ---------------------------------------------------------------------------
# Enforcement
# ---------------------------------------------------------------------------

def _force_stop_youtube(ip: str) -> None:
    """Force-stop whichever YouTube package is installed on the device."""
    installed = _shell(ip, "pm list packages", timeout=10)
    for pkg in YOUTUBE_PACKAGES:
        if f"package:{pkg}" in installed:
            subprocess.run(
                ["adb", "-s", _dev(ip), "shell", "am", "force-stop", pkg],
                capture_output=True,
                timeout=8,
            )
            log.info("%s — Force-stopped %s", ip, pkg)


# ---------------------------------------------------------------------------
# Per-device processing
# ---------------------------------------------------------------------------

def _process_device(ip: str, today: str, daily_limit: float, state: DeviceState) -> None:
    if not _ensure_connected(ip):
        state.last_short_ts = 0.0
        return

    if not _is_youtube_active(ip):
        state.last_short_ts = 0.0
        return

    xml = _dump_ui(ip)
    if not xml:
        state.last_short_ts = 0.0
        return

    now = time.monotonic()

    if not _is_shorts(state, ip, xml):
        state.last_short_ts = 0.0
        return

    # Shorts is actively playing — accumulate elapsed time
    prev_ts = state.last_short_ts
    state.last_short_ts = now  # Set FIRST so first detection counts on next poll
    if prev_ts > 0:
        delta = now - prev_ts
        if delta <= state.delta_cap:
            db.add_seconds(ip, today, delta)
            log.debug("%s — +%.1fs", ip, delta)

    total = db.get_seconds(ip, today)
    remaining = max(0.0, daily_limit - total)
    log.info(
        "%s — Shorts: %.0fs / %.0fs used  (%.0fs remaining)",
        ip, total, daily_limit, remaining,
    )

    if total >= daily_limit:
        log.info("%s — Daily limit reached (%.0fs). Stopping YouTube.", ip, daily_limit)
        _force_stop_youtube(ip)
        state.last_short_ts = 0.0


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main() -> None:
    p = argparse.ArgumentParser(
        description="ClawShorts — Fire TV YouTube Shorts time limiter"
    )
    p.add_argument(
        "--interval", type=int, default=3,
        metavar="SECONDS", help="Poll interval in seconds (default: 3)",
    )
    p.add_argument("--debug", action="store_true", help="Enable verbose debug logging")
    args = p.parse_args()

    _setup_logging(args.debug)

    # Load devices + global config from SQLite — single source of truth
    db.init_db()
    devices = _load_devices_from_db()
    if not devices:
        log.error("No devices found in database. Run 'shorts setup <IP>' first.")
        sys.exit(1)

    try:
        global_cfg = db.get_all_config()
        log.debug("Global config loaded: %s", global_cfg)
    except Exception:
        log.warning("Could not load global config from DB; using hardcoded defaults")
        global_cfg = {}

    STATE_DIR.mkdir(parents=True, exist_ok=True)

    ctx = DaemonContext(devices, global_cfg, args.interval)

    # Wire up signals
    signal.signal(signal.SIGINT, _make_signal_handler(ctx))
    signal.signal(signal.SIGTERM, _make_signal_handler(ctx))

    log.info(
        "ClawShorts started | %d device(s) | poll=%ds | db=%s",
        len(devices), args.interval, db.DB_PATH,
    )

    try:
        while not ctx.shutdown:
            today = time.strftime("%Y-%m-%d")

            for dev in ctx.devices:
                ip = dev["ip"]
                daily_limit = dev["limit_val"]
                state = ctx.states[ip]

                # Midnight rollover
                if state.date and state.date != today:
                    log.info("%s — New day (%s). Daily limit reset.", ip, today)
                    state.last_short_ts = 0.0
                state.date = today

                try:
                    _process_device(ip, today, daily_limit, state)
                except Exception:
                    log.exception("Unexpected error processing %s", ip)

                if ctx.shutdown:
                    break

            time.sleep(ctx.interval)

            # Heartbeat
            now = time.time()
            if now - ctx.last_heartbeat >= HEARTBEAT_INTERVAL:
                log.info("HEARTBEAT — daemon alive")
                ctx.last_heartbeat = now

    finally:
        log.info("ClawShorts daemon stopped.")


if __name__ == "__main__":
    main()


path: scripts/clawshorts-daemon.sh
content: #!/bin/bash
#
# Fire TV Shorts Limiter Daemon (bash fallback)
# Delegates to Python daemon for production use.
#
set -euo pipefail

IPS="${1:-}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PYTHON_DAEMON="${SCRIPT_DIR}/clawshorts-daemon.py"
STATE_DIR="${HOME}/.clawshorts"
LOG_DIR="${STATE_DIR}/logs"

# Threshold: video player width must be less than 80% of screen width to count
# as Shorts.  This is SHORTS_WIDTH_THRESHOLD (0.90) expressed as a percentage.
SHORTS_WIDTH_PCT=90

YOUTUBE_PKG="com.google.android.youtube.tv"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

log() {
    echo "[$(date '+%H:%M:%S')] $*"
}

validate_ip() {
    local ip="$1"
    # Delegate to Python validator for consistency with Python daemon
    python3 -c "
import sys, re
ip = '$ip'
if not re.match(r'^(\d{1,3}\.){3}\d{1,3}$', ip):
    sys.exit(1)
if any(int(o) > 255 for o in ip.split('.')):
    sys.exit(1)
" 2>/dev/null
}

for ip in $(echo "$IPS" | tr ',' ' '); do
    ip=$(echo "$ip" | xargs)
    if ! validate_ip "$ip"; then
        echo "Error: Invalid IP: $ip" >&2
        exit 1
    fi
done

mkdir -p "$LOG_DIR" "$STATE_DIR"
LOG="${LOG_DIR}/daemon.log"

get_state_file() {
    local ip="$1"
    local slug="${ip//./-}"
    echo "${STATE_DIR}/state-${slug}.txt"
}

get_session_file() {
    local ip="$1"
    local slug="${ip//./-}"
    echo "${STATE_DIR}/session-${slug}.txt"
}

check_adb() {
    adb devices 2>/dev/null | grep -q "^${1}:5555.*device$"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

log "========================================="
log "Shorts Limiter (bash daemon)"
log "IPs: $IPS"
log "Threshold: ${SHORTS_WIDTH_PCT}% (matches Python daemon)"
log "========================================="

# Detect if Python daemon is available and prefer it
if [[ -x "$PYTHON_DAEMON" ]] && [[ "${FORCE_BASH_DAEMON:-}" != "1" ]]; then
    log "Using Python daemon: $PYTHON_DAEMON"
    exec python3 "$PYTHON_DAEMON" "$IPS" "$@"
fi

# Bash fallback (deprecated)
log "WARNING: Using bash daemon (deprecated). Prefer Python daemon."

while true; do
    for ip in $(echo "$IPS" | tr ',' ' '); do
        ip=$(echo "$ip" | xargs)
        if ! validate_ip "$ip"; then
            log "[SECURITY] Invalid IP: $ip"
            continue
        fi

        if ! check_adb "$ip"; then
            log "[ADB] OFF $ip"
            adb connect "$ip:5555" >/dev/null 2>&1
            continue
        fi

        log "[ADB] OK $ip"

        if adb -s "$ip:5555" shell dumpsys activity activities 2>/dev/null | grep -q youtube; then
            log "[WATCH] YouTube on $ip"

            adb -s "$ip:5555" shell uiautomator dump /sdcard/ui.xml 2>/dev/null
            adb -s "$ip:5555" pull /sdcard/ui.xml /tmp/ui.xml 2>/dev/null

            pct=$(python3 -c "
import re
try:
    c=open('/tmp/ui.xml').read()
    m=re.search(r'focused=\"true\"[^>]*bounds=\"\[(\d+),(\d+)\]\[(\d+),(\d+)\]\"',c)
    if not m:
        print(100)
    else:
        fw=int(m.group(3))-int(m.group(1))
        m=re.search(r'bounds=\"\[(\d+),(\d+)\]\[(\d+),(\d+)\]\"',c)
        if not m:
            print(100)
        else:
            sw=int(m.group(3))-int(m.group(1))
            print(int(fw/sw*100))
except:
    print(100)
" 2>/dev/null || echo "100")

            log "[RATIO] ${pct}%"

            # Use SHORTS_WIDTH_PCT as threshold (90 = 90% = SHORTS_WIDTH_THRESHOLD 0.90)
            if [[ "${pct}" -lt "${SHORTS_WIDTH_PCT}" ]]; then
                state_file=$(get_state_file "$ip")
                session_file=$(get_session_file "$ip")
                cnt=0 lim=5
                prev_session=""

                if [[ -f "$session_file" ]]; then
                    prev_session=$(cat "$session_file" 2>/dev/null || echo "")
                fi

                if [[ "$prev_session" = "1" ]]; then
                    log "[SHORTS] Same session, not counting again"
                else
                    echo "1" > "$session_file"

                    if [[ -f "$state_file" ]]; then
                        cnt=$(grep "^COUNT=" "$state_file" 2>/dev/null | head -1 | cut -d= -f2 || echo "0")
                        lim=$(grep "^LIMIT=" "$state_file" 2>/dev/null | head -1 | cut -d= -f2 || echo "5")
                    fi

                    if [[ "$cnt" -lt "$lim" ]]; then
                        cnt=$((cnt + 1))
                        cat > "$state_file" << EOF
DATE=$(date +%Y-%m-%d)
COUNT=$cnt
LIMIT=$lim
EOF
                        log "[SHORTS] #$cnt/$lim"

                        if [[ "$cnt" -ge "$lim" ]]; then
                            log "[LIMIT] Reached! Force-stopping YouTube..."
                            adb -s "$ip:5555" shell am force-stop "$YOUTUBE_PKG" 2>/dev/null || true
                        fi
                    else
                        adb -s "$ip:5555" shell am force-stop "$YOUTUBE_PKG" 2>/dev/null || true
                        log "[BLOCK] Limit reached"
                    fi
                fi
            else
                session_file=$(get_session_file "$ip")
                if [[ -f "$session_file" ]] && [[ "$(cat "$session_file" 2>/dev/null)" = "1" ]]; then
                    echo "0" > "$session_file"
                    log "[SHORTS] Exited Shorts view"
                fi
            fi
        fi
    done
    sleep 10
done


path: scripts/clawshorts-healthcheck.py
content: #!/usr/bin/env python3
"""ClawShorts Health Check - Auto-restarts daemon if hung.

This script monitors the main daemon and restarts it if it becomes unresponsive.
Runs as a sidecar to ensure the blocker is always active.

Usage:
    python3 clawshorts-healthcheck.py 192.168.1.239 &
    # Or run via cron: */5 * * * * python3 /path/to/clawshorts-healthcheck.py 192.168.1.239
"""
from __future__ import annotations

import logging
import signal
import subprocess
import sys
import time
from pathlib import Path

__all__ = []

log = logging.getLogger(__name__)

DAEMON_SCRIPT = Path(__file__).parent / "clawshorts-daemon.py"
STATE_DIR = Path.home() / ".clawshorts"
LOG_FILE = STATE_DIR / "healthcheck.log"

# How old the log file must be (seconds) to consider daemon dead
LOG_MAX_AGE_SECONDS = 120


def _log(msg: str) -> None:
    """Append a timestamped message to the healthcheck log (mode='a')."""
    timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{timestamp}] {msg}\n"
    LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
    LOG_FILE.write_text(LOG_FILE.read_text(errors="replace") + line)
    print(line.strip(), file=sys.stdout)


def is_daemon_healthy() -> bool:
    """Check if exactly ONE daemon is running and responsive."""
    # Check process count
    try:
        result = subprocess.run(
            ["pgrep", "-f", "clawshorts-daemon.py"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        pids = [p for p in result.stdout.strip().split("\n") if p]
        pid_count = len(pids)

        if pid_count == 0:
            _log("Daemon not running")
            return False
        if pid_count > 1:
            _log(f"Multiple daemons running ({pid_count}), need cleanup")
            return False
    except subprocess.TimeoutExpired:
        _log("Process check timed out")
        return False
    except OSError as e:
        _log(f"Process check failed: {e}")
        return False

    # Check heartbeat in daemon log
    try:
        daemon_log = STATE_DIR / "daemon.log"
        if daemon_log.exists():
            # Verify log is recent
            mtime = daemon_log.stat().st_mtime
            age = time.time() - mtime
            content = daemon_log.read_text(errors="replace")
            if "HEARTBEAT" not in content:
                _log("No heartbeat found in log")
                return False
            if age > LOG_MAX_AGE_SECONDS:
                _log(f"Daemon log stale ({age:.0f}s old)")
                return False
        else:
            _log("Daemon log not found")
            return False
    except OSError as e:
        _log(f"Log check failed: {e}")
        return False

    _log("Daemon healthy")
    return True


def kill_daemon() -> None:
    """Kill all clawshorts daemon processes and stop launchctl."""
    try:
        subprocess.run(["launchctl", "stop", "com.fink.clawshorts"], timeout=5)
        time.sleep(1)
        subprocess.run(["pkill", "-9", "-f", "clawshorts-daemon.py"], timeout=10)
        time.sleep(1)
    except subprocess.TimeoutExpired:
        _log("Kill command timed out")
    except OSError as e:
        _log(f"Error killing daemon: {e}")


def start_daemon(ip: str) -> None:
    """Start the daemon for the given IP."""
    try:
        daemon_out = STATE_DIR / "daemon.log"
        subprocess.Popen(
            [sys.executable, str(DAEMON_SCRIPT), ip],
            stdout=open(daemon_out, "a"),
            stderr=subprocess.STDOUT,
            start_new_session=True,
        )
        _log(f"Started daemon for {ip}")
    except OSError as e:
        _log(f"Error starting daemon: {e}")


def main() -> None:
    if len(sys.argv) < 2:
        print("Usage: python3 clawshorts-healthcheck.py <ip>")
        sys.exit(1)

    ip = sys.argv[1]
    _log(f"Health check for {ip}")

    if is_daemon_healthy():
        _log("Daemon is healthy, skipping restart")
        sys.exit(0)

    _log("Daemon unhealthy, restarting...")
    kill_daemon()
    time.sleep(2)

    # Verify no daemons running after kill
    result = subprocess.run(
        ["pgrep", "-f", "clawshorts-daemon.py"],
        capture_output=True,
        text=True,
    )
    if result.stdout.strip():
        _log("Warning: Daemons still running after kill")

    start_daemon(ip)
    time.sleep(1)

    # Verify exactly one started
    result = subprocess.run(
        ["pgrep", "-f", "clawshorts-daemon.py"],
        capture_output=True,
        text=True,
    )
    pids = [p for p in result.stdout.strip().split("\n") if p]
    _log(f"Daemon restarted ({len(pids)} running)")


if __name__ == "__main__":
    main()


path: scripts/clawshorts.sh
content: #!/bin/bash
#
# ClawShorts - Multi-Device Shorts Limiter
# Delegates device management to Python CLI
#

set -euo pipefail

# Resolve symlinks to get the real script location
_real_script() {
    local src="$1"
    while [[ -L "$src" ]]; do
        src="$(readlink "$src")"
    done
    dirname "$src"
}
SCRIPT_DIR="$(_real_script "$0")"
CLAWSHORTS_PY="${SCRIPT_DIR}/../src/clawshorts/cli.py"
LOG_FILE="${HOME}/.clawshorts.log"

# Default config
DEFAULT_LIMIT=300

# ---------- IP validation (bash fallback — Python does the real check) ----------
# Rejects: empty, non-numeric, octets >255, and obvious non-private IPs.
# Covers the common cases: 192.168.x.x, 10.x.x.x, 172.16–31.x.x
validate_ip_bash() {
    local ip="$1"
    local re='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
    [[ "$ip" =~ $re ]] || return 1
    local IFS='.' && set -- $ip
    # shellcheck disable=SC2086
    [[ $1 -le 255 && $2 -le 255 && $3 -le 255 && $4 -le 255 ]] || return 1
    # Private-range quick check (rough — Python addr.is_private is authoritative)
    if [[ $1 -eq 10 ]]; then return 0; fi
    if [[ $1 -eq 172 && $2 -ge 16 && $2 -le 31 ]]; then return 0; fi
    if [[ $1 -eq 192 && $2 -eq 168 ]]; then return 0; fi
    if [[ $1 -eq 127 ]]; then return 0; fi
    return 1
}

# ============ PYTHON CLI ============
run_python_cli() {
    local cmd="$1"
    shift
    PYTHONPATH="${SCRIPT_DIR}/../src" python3 "$CLAWSHORTS_PY" "$cmd" "$@"
}

# ============ COMMANDS THAT USE PYTHON ============
cmd_setup() {
    local ip="${1:-}"
    if [[ -z "$ip" ]] || ! validate_ip_bash "$ip"; then
        echo "Error: valid private IP required (e.g. 192.168.1.100)"
        return 1
    fi
    run_python_cli setup "$@"
}

cmd_add() {
    local ip="${1:-}"
    if [[ -z "$ip" ]] || ! validate_ip_bash "$ip"; then
        echo "Error: valid private IP required (e.g. 192.168.1.100)"
        return 1
    fi
    run_python_cli add "$@"
}

cmd_remove() {
    run_python_cli remove "$@"
}

cmd_list() {
    run_python_cli list "$@"
}

cmd_status() {
    run_python_cli status "$@"
}

cmd_reset() {
    # Delegate to Python CLI — uses SQLite only (no text file state)
    run_python_cli reset "${1:-}"
}

cmd_enable() {
    run_python_cli enable "$@"
}

cmd_disable() {
    run_python_cli disable "$@"
}

cmd_logs() {
    local lines="${1:-50}"
    local log_path="${HOME}/.clawshorts/daemon.log"
    if [[ ! -f "$log_path" ]]; then
        echo "No daemon.log found."
        return 1
    fi
    tail -n "$lines" "$log_path"
}

cmd_config() {
    run_python_cli config "$@"
}

cmd_detect() {
    run_python_cli detect "$@"
}

# ============ COMMANDS THAT USE ADB (KEEP IN BASH) ============
check_adb() {
    command -v adb &> /dev/null
}

install_adb() {
    echo "Installing ADB..."
    if command -v apt-get &> /dev/null; then
        sudo apt-get update -qq && sudo apt-get install -y adb
    elif command -v brew &> /dev/null; then
        brew install android-platform-tools
    else
        echo "ERROR: No supported package manager found (apt-get or brew)."
        echo "  Install ADB manually: https://developer.android.com/tools/adb"
        return 1
    fi
    echo "ADB installed successfully"
}

cmd_connect() {
    local ip="${1:-}"
    if [[ -z "$ip" ]] || ! validate_ip_bash "$ip"; then
        echo "Error: valid private IP required (e.g. 192.168.1.100)"
        return 1
    fi
    run_python_cli connect "$@"
}

cmd_start() {
    echo "Starting ClawShorts daemon..."
    echo "Daemon reads device config from SQLite automatically."
    PYTHONPATH="$SCRIPT_DIR/../src" exec python3 "$SCRIPT_DIR/clawshorts-daemon.py"
}

cmd_stop() {
    local pids
    pids=$(pgrep -f "clawshorts-daemon" 2>/dev/null || true)
    if [[ -z "$pids" ]]; then
        echo "Daemon not running"
        return 0
    fi
    # Graceful shutdown: SIGTERM first, wait 2s, then SIGKILL only if still alive
    kill -TERM $pids 2>/dev/null || true
    sleep 2
    for pid in $pids; do
        kill -0 "$pid" 2>/dev/null && kill -9 "$pid"
    done
    echo "✅ Stopped"
}

cmd_install() {
    # Create bin symlink so `shorts` works from anywhere
    local bin_dir="/opt/homebrew/bin"
    local symlink="${bin_dir}/shorts"
    if [[ ! -L "$symlink" ]] && [[ ! -e "$symlink" ]]; then
        if ln -s "$SCRIPT_DIR/clawshorts.sh" "$symlink" 2>/dev/null; then
            echo "✅ Linked: shorts → $symlink"
        else
            echo "⚠️  Could not create $symlink (need sudo?) — add $SCRIPT_DIR to your PATH instead"
        fi
    fi

    if [[ "$(uname)" == "Darwin" ]]; then
        _install_launchd
    else
        _install_systemd
    fi

    # Verify installation
    echo ""
    echo "Running verification..."
    sleep 3
    cmd_status
}

_install_launchd() {
    local plist="${HOME}/Library/LaunchAgents/com.fink.clawshorts.plist"
    mkdir -p "$(dirname "$plist")"
    
    cat > "$plist" << PLISTEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.fink.clawshorts</string>
    <key>ProgramArguments</key>
    <array>
        <string>python3</string>
        <string>${SCRIPT_DIR}/clawshorts-daemon.py</string>
        <string>--debug</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>${LOG_FILE}</string>
    <key>StandardErrorPath</key>
    <string>${LOG_FILE}</string>
</dict>
</plist>
PLISTEOF
    
    # Unload existing first, then load fresh
    launchctl unload "$plist" 2>/dev/null || true
    launchctl load "$plist" 2>/dev/null || true
    
    echo "✅ Installed auto-start (launchd)"
    echo "   Daemon reads devices from SQLite automatically."
}

_install_systemd() {
    local unit_dir="${HOME}/.config/systemd/user"
    local unit_file="${unit_dir}/clawshorts.service"
    mkdir -p "$unit_dir"
    
    cat > "$unit_file" << UNITEOF
[Unit]
Description=ClawShorts YouTube Shorts Limiter
After=network.target

[Service]
Type=simple
ExecStart=$(command -v python3) ${SCRIPT_DIR}/clawshorts-daemon.py
Restart=on-failure
RestartSec=10
StandardOutput=append:${LOG_FILE}
StandardError=append:${LOG_FILE}

[Install]
WantedBy=default.target
UNITEOF
    
    systemctl --user daemon-reload
    systemctl --user enable --now clawshorts.service
    echo "✅ Installed auto-start (systemd)"
}

cmd_uninstall() {
    if [[ "$(uname)" == "Darwin" ]]; then
        local plist="${HOME}/Library/LaunchAgents/com.fink.clawshorts.plist"
        launchctl unload "$plist" 2>/dev/null || true
        rm -f "$plist"
    else
        systemctl --user disable --now clawshorts.service 2>/dev/null || true
        rm -f "${HOME}/.config/systemd/user/clawshorts.service"
        systemctl --user daemon-reload 2>/dev/null || true
    fi
    cmd_stop
    echo "✅ Uninstalled"
}

# ============ MAIN ============
# Parse command and arguments
_real_cmd="${1:-status}"

if [[ "$_real_cmd" == "shorts" ]]; then
    _verb="${2:-status}"
    if [[ $# -ge 3 ]]; then
        set -- "$_verb" "${@:3}"
    else
        set -- "$_verb"
    fi
    _cmd="$_verb"
else
    _cmd="$_real_cmd"
fi

case "$_cmd" in
    setup)    cmd_setup "${2:-}" "${3:-}" ;;
    add)      cmd_add "${2:-}" "${3:-}" ;;
    remove)   cmd_remove "${2:-}" ;;
    list)     cmd_list ;;
    status)   cmd_status "${2:-}" ;;
    reset)    cmd_reset "${2:-}" ;;
    enable)   cmd_enable "${2:-}" ;;
    disable)  cmd_disable "${2:-}" ;;
    connect)  cmd_connect "${2:-}" ;;
    start)    cmd_start "${2:-}" "${3:-}" ;;
    stop)     cmd_stop ;;
    install)  cmd_install "${2:-}" "${3:-}" ;;
    uninstall) cmd_uninstall ;;
    history)  run_python_cli history ${@:2} ;;
    logs)
        cmd_logs
        ;;
    config)   shift; cmd_config "$@" ;;
    detect)   shift; cmd_detect "$@" ;;
    *)
        echo "⚡ ClawShorts - YouTube Shorts Blocker"
        echo ""
        echo "Usage: shorts <command> [options]"
        echo ""
        echo "Commands:"
        echo "  shorts status        Show quota status"
        echo "  shorts reset        Reset today's quota"
        echo "  shorts stop         Stop the limiter"
        echo "  shorts start        Start the limiter"
        echo "  shorts install      Auto-start at login"
        echo "  shorts history      Show watch history (last 30 days)"
        echo "  shorts logs         Show debug logs"
        echo "  shorts uninstall    Remove everything"
        echo ""
        echo "  shorts setup <IP> [NAME]   First-time setup"
        echo "  shorts add <IP> [NAME]      Add new device"
        echo "  shorts remove <IP>          Remove device"
        echo "  shorts list                  List all devices"
        echo "  shorts connect <IP>          Connect via ADB + detect screen"
        echo "  shorts enable <IP>           Enable device"
        echo "  shorts disable <IP>          Disable device"
        echo ""
        echo "  shorts config [show|get|set|reset]   View/set global or per-device config"
        echo "  shorts detect <IP>                   Re-detect screen resolution via ADB"
        echo ""
        echo "Quick Start:"
        echo "  shorts setup 192.168.1.100 living-room"
        exit 1
        ;;
esac


path: scripts/clawshorts_db.py
content: """ClawShorts SQLite helper — tracks daily YouTube Shorts watch time.

One table:  daily_usage(ip, date, seconds)
Database:   ~/.clawshorts/clawshorts.db
"""
from __future__ import annotations

import logging
import os
import sqlite3
from contextlib import contextmanager
from pathlib import Path

__all__ = [
    "add_device",
    "add_seconds",
    "get_config",
    "get_device",
    "get_device_config",
    "get_devices",
    "get_seconds",
    "get_seconds_readonly",
    "init_db",
    "remove_device",
    "reset_all",
    "reset_device",
    "reset_device_config",
    "set_config",
    "update_device",
    "update_device_screen",
    "update_device_threshold",
    "migrate_schema",
    "DB_DIR",
    "DB_PATH",
    "DEFAULT_LIMIT",
    # Config key constants
    "CONFIG_KEYS",
    "KEY_SHORTS_WIDTH_THRESHOLD",
    "KEY_SHORTS_MAX_ASPECT_RATIO",
    "KEY_SHORTS_FALLBACK_HEIGHT_RATIO",
    "KEY_SHORTS_DELTA_CAP",
    "KEY_DEFAULT_SCREEN_WIDTH",
    "KEY_DEFAULT_SCREEN_HEIGHT",
    # Hardcoded defaults (last resort fallback)
    "DEFAULTS",
]

log = logging.getLogger(__name__)

DB_DIR = Path.home() / ".clawshorts"
DB_PATH = DB_DIR / "clawshorts.db"

# Default daily limit in seconds
DEFAULT_LIMIT: float = 300.0

# ---------------------------------------------------------------------------
# Config key constants
# ---------------------------------------------------------------------------

KEY_SHORTS_WIDTH_THRESHOLD = "shorts_width_threshold"
KEY_SHORTS_MAX_ASPECT_RATIO = "shorts_max_aspect_ratio"
KEY_SHORTS_FALLBACK_HEIGHT_RATIO = "shorts_fallback_height_ratio"
KEY_SHORTS_DELTA_CAP = "shorts_delta_cap"
KEY_DEFAULT_SCREEN_WIDTH = "default_screen_width"
KEY_DEFAULT_SCREEN_HEIGHT = "default_screen_height"

CONFIG_KEYS = [
    KEY_SHORTS_WIDTH_THRESHOLD,
    KEY_SHORTS_MAX_ASPECT_RATIO,
    KEY_SHORTS_FALLBACK_HEIGHT_RATIO,
    KEY_SHORTS_DELTA_CAP,
    KEY_DEFAULT_SCREEN_WIDTH,
    KEY_DEFAULT_SCREEN_HEIGHT,
]

# Last-resort hardcoded defaults (used only if DB read fails)
DEFAULTS = {
    KEY_SHORTS_WIDTH_THRESHOLD: 0.30,
    KEY_SHORTS_MAX_ASPECT_RATIO: 1.3,
    KEY_SHORTS_FALLBACK_HEIGHT_RATIO: 0.4,
    KEY_SHORTS_DELTA_CAP: 300.0,
    KEY_DEFAULT_SCREEN_WIDTH: 1920.0,
    KEY_DEFAULT_SCREEN_HEIGHT: 1080.0,
}

# Config key → devices table column mapping (None = not per-device overridable)
KEY_TO_DEVICE_COLUMN = {
    KEY_SHORTS_WIDTH_THRESHOLD: "width_threshold",
    KEY_SHORTS_MAX_ASPECT_RATIO: "max_aspect_ratio",
    KEY_SHORTS_FALLBACK_HEIGHT_RATIO: "fallback_height_ratio",
    KEY_SHORTS_DELTA_CAP: "delta_cap",
    KEY_DEFAULT_SCREEN_WIDTH: "screen_width",
    KEY_DEFAULT_SCREEN_HEIGHT: "screen_height",
}


# ---------------------------------------------------------------------------
# Connection context manager
# ---------------------------------------------------------------------------

@contextmanager
def _db(*, immediate: bool = False):
    """Yield a WAL-mode SQLite connection that auto-commits."""
    DB_DIR.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(str(DB_PATH), timeout=10)
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA busy_timeout=5000")
    if immediate:
        conn.execute("BEGIN IMMEDIATE")
    try:
        yield conn
        conn.commit()
    except BaseException:
        conn.rollback()
        raise
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------

def init_db() -> None:
    """Create the devices and daily_usage tables if they don't exist."""
    with _db() as conn:
        conn.execute("""
            CREATE TABLE IF NOT EXISTS devices (
                ip        TEXT PRIMARY KEY,
                name      TEXT,
                limit_val REAL NOT NULL DEFAULT 300.0,
                enabled   INTEGER NOT NULL DEFAULT 1
            )
        """)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS daily_usage (
                ip      TEXT NOT NULL,
                date    TEXT NOT NULL,
                seconds REAL NOT NULL DEFAULT 0.0,
                PRIMARY KEY (ip, date)
            )
        """)
    # Run schema migration (adds config table + new device columns if needed)
    migrate_schema()
    # Security: set restrictive permissions (owner only)
    try:
        os.chmod(DB_PATH, 0o600)
    except OSError:
        pass
    log.info("DB ready: %s", DB_PATH)


# ---------------------------------------------------------------------------
# Schema migration
# ---------------------------------------------------------------------------

def migrate_schema() -> None:
    """Zero-downtime migration: add config table and device columns if not exist."""
    with _db() as conn:
        # --- config table ---
        conn.execute("""
            CREATE TABLE IF NOT EXISTS config (
                key          TEXT PRIMARY KEY,
                value        REAL NOT NULL,
                description  TEXT,
                updated_at   TEXT DEFAULT (datetime('now'))
            )
        """)

        # Seed global defaults only for keys not yet present
        defaults = [
            (KEY_SHORTS_WIDTH_THRESHOLD, 0.30, "player width must be < this ratio of screen width"),
            (KEY_SHORTS_MAX_ASPECT_RATIO, 1.3, "portrait aspect ratio cap; ar < this = portrait"),
            (KEY_SHORTS_FALLBACK_HEIGHT_RATIO, 0.4, "fallback: player height must exceed this ratio of screen height"),
            (KEY_SHORTS_DELTA_CAP, 300.0, "max seconds accumulated per poll (prevents clock jumps)"),
            (KEY_DEFAULT_SCREEN_WIDTH, 1920.0, "fallback assumed screen width"),
            (KEY_DEFAULT_SCREEN_HEIGHT, 1080.0, "fallback assumed screen height"),
        ]
        for key, value, description in defaults:
            conn.execute(
                """
                INSERT OR IGNORE INTO config (key, value, description)
                VALUES (?, ?, ?)
                """,
                (key, value, description),
            )

        # --- devices table new columns ---
        new_device_columns = [
            ("screen_width", "INTEGER"),
            ("screen_height", "INTEGER"),
            ("width_threshold", "REAL"),
            ("max_aspect_ratio", "REAL"),
            ("fallback_height_ratio", "REAL"),
            ("delta_cap", "REAL"),
        ]
        # Get existing columns
        existing = {row[1] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
        for col_name, col_type in new_device_columns:
            if col_name not in existing:
                conn.execute(f"ALTER TABLE devices ADD COLUMN {col_name} {col_type}")
        # updated_at column
        if "updated_at" not in existing:
            conn.execute("ALTER TABLE devices ADD COLUMN updated_at TEXT")
            conn.execute("UPDATE devices SET updated_at = datetime('now') WHERE updated_at IS NULL")

    log.info("Schema migration complete.")


# ---------------------------------------------------------------------------
# Device management
# ---------------------------------------------------------------------------

def add_device(ip: str, name: str | None = None, limit_val: float = DEFAULT_LIMIT) -> dict:
    """Add a device. Replaces if IP already exists. Returns the device dict."""
    with _db(immediate=True) as conn:
        conn.execute(
            """
            INSERT INTO devices(ip, name, limit_val, enabled)
            VALUES (?, ?, ?, 1)
            ON CONFLICT(ip) DO UPDATE SET
                name = excluded.name,
                limit_val = excluded.limit_val,
                enabled = 1
            """,
            (ip, name, limit_val),
        )
        return {"ip": ip, "name": name, "limit_val": limit_val, "enabled": True}


def get_device(ip: str) -> dict | None:
    """Return a device dict or None if not found. Includes all per-device config columns."""
    conn = sqlite3.connect(str(DB_PATH), timeout=5, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        row = conn.execute(
            """SELECT ip, name, limit_val, enabled,
                      screen_width, screen_height,
                      width_threshold, max_aspect_ratio,
                      fallback_height_ratio, delta_cap
               FROM devices WHERE ip=?""",
            (ip,),
        ).fetchone()
        if row:
            return {
                "ip": row[0],
                "name": row[1],
                "limit_val": row[2],
                "enabled": bool(row[3]),
                "screen_width": row[4],
                "screen_height": row[5],
                "width_threshold": row[6],
                "max_aspect_ratio": row[7],
                "fallback_height_ratio": row[8],
                "delta_cap": row[9],
            }
        return None
    finally:
        conn.close()


def get_devices() -> list[dict]:
    """Return all configured devices. Includes per-device config columns."""
    conn = sqlite3.connect(str(DB_PATH), timeout=5, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        rows = conn.execute(
            """SELECT ip, name, limit_val, enabled,
                      screen_width, screen_height,
                      width_threshold, max_aspect_ratio,
                      fallback_height_ratio, delta_cap
               FROM devices WHERE enabled=1"""
        ).fetchall()
        return [
            {
                "ip": r[0],
                "name": r[1],
                "limit_val": r[2],
                "enabled": bool(r[3]),
                "screen_width": r[4],
                "screen_height": r[5],
                "width_threshold": r[6],
                "max_aspect_ratio": r[7],
                "fallback_height_ratio": r[8],
                "delta_cap": r[9],
            }
            for r in rows
        ]
    finally:
        conn.close()


def remove_device(ip: str) -> bool:
    """Remove a device. Returns True if found and deleted."""
    with _db(immediate=True) as conn:
        cur = conn.execute("DELETE FROM devices WHERE ip=?", (ip,))
        return cur.rowcount > 0


def update_device(ip: str, **kwargs: any) -> dict | None:
    """Update device fields. Supported: name, limit_val, enabled, and all per-device config columns."""
    allowed = {
        "name", "limit_val", "enabled",
        "screen_width", "screen_height",
        "width_threshold", "max_aspect_ratio",
        "fallback_height_ratio", "delta_cap",
    }
    updates = {k: v for k, v in kwargs.items() if k in allowed}
    if not updates:
        return get_device(ip)
    set_clause = ", ".join(f"{k}=?" for k in updates)
    values = list(updates.values()) + [ip]
    with _db(immediate=True) as conn:
        conn.execute(f"UPDATE devices SET {set_clause} WHERE ip=?", values)
    return get_device(ip)


# ---------------------------------------------------------------------------
# Global config management
# ---------------------------------------------------------------------------

def get_config(key: str) -> float | None:
    """Return a global config value, or None if not found."""
    conn = sqlite3.connect(str(DB_PATH), timeout=5, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        row = conn.execute(
            "SELECT value FROM config WHERE key=?", (key,)
        ).fetchone()
        return float(row[0]) if row else None
    finally:
        conn.close()


def set_config(key: str, value: float) -> None:
    """Set a global config value (upsert)."""
    with _db(immediate=True) as conn:
        conn.execute(
            """INSERT INTO config (key, value, updated_at)
               VALUES (?, ?, datetime('now'))
               ON CONFLICT(key) DO UPDATE SET
                   value = excluded.value,
                   updated_at = datetime('now')""",
            (key, value),
        )


def get_all_config() -> dict[str, float]:
    """Return all global config key-value pairs."""
    conn = sqlite3.connect(str(DB_PATH), timeout=5, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        rows = conn.execute("SELECT key, value FROM config").fetchall()
        return {key: float(value) for key, value in rows}
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# Per-device config helpers
# ---------------------------------------------------------------------------

def get_device_config(ip: str) -> dict | None:
    """Return per-device config columns, or None if device not found."""
    return get_device(ip)


def update_device_screen(ip: str, width: int, height: int) -> dict | None:
    """Update device screen resolution."""
    return update_device(ip, screen_width=width, screen_height=height)


def update_device_threshold(
    ip: str,
    width_threshold: float | None = None,
    max_aspect_ratio: float | None = None,
    fallback_height_ratio: float | None = None,
    delta_cap: float | None = None,
) -> dict | None:
    """Update one or more detection thresholds for a device."""
    kwargs = {}
    if width_threshold is not None:
        kwargs["width_threshold"] = width_threshold
    if max_aspect_ratio is not None:
        kwargs["max_aspect_ratio"] = max_aspect_ratio
    if fallback_height_ratio is not None:
        kwargs["fallback_height_ratio"] = fallback_height_ratio
    if delta_cap is not None:
        kwargs["delta_cap"] = delta_cap
    if not kwargs:
        return get_device(ip)
    return update_device(ip, **kwargs)


def reset_device_config(ip: str) -> dict | None:
    """Clear per-device threshold overrides → NULL (falls back to global defaults).

    Note: screen_width/screen_height are NOT reset — they are stable device
    properties set by auto-detection and should persist across config resets.
    """
    threshold_cols = [
        "width_threshold", "max_aspect_ratio",
        "fallback_height_ratio", "delta_cap",
    ]
    set_clause = ", ".join(f"{col}=NULL" for col in threshold_cols)
    with _db(immediate=True) as conn:
        conn.execute(f"UPDATE devices SET {set_clause} WHERE ip=?", (ip,))
    return get_device(ip)


# ---------------------------------------------------------------------------
# Read / write
# ---------------------------------------------------------------------------

def add_seconds(ip: str, date: str, delta: float) -> None:
    """Add delta seconds to the daily total for the device.

    Non-positive deltas are ignored. Large deltas are capped to prevent abuse.
    """
    if delta <= 0:
        return
    # Cap delta to prevent abuse / clock skew (max 5 minutes per poll)
    delta = min(delta, 300)
    with _db(immediate=True) as conn:
        conn.execute(
            """
            INSERT INTO daily_usage(ip, date, seconds) VALUES(?, ?, ?)
            ON CONFLICT(ip, date) DO UPDATE SET seconds = seconds + excluded.seconds
            """,
            (ip, date, delta),
        )


def get_seconds(ip: str, date: str) -> float:
    """Return the total seconds watched for the device on the given date."""
    with _db() as conn:
        row = conn.execute(
            "SELECT seconds FROM daily_usage WHERE ip=? AND date=?", (ip, date)
        ).fetchone()
        return float(row[0]) if row else 0.0


def get_seconds_readonly(ip: str, date: str) -> float:
    """Return seconds for the device/date without transaction overhead.

    Use this for read-only, non-time-sensitive queries.
    """
    conn = sqlite3.connect(str(DB_PATH), timeout=5, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        row = conn.execute(
            "SELECT seconds FROM daily_usage WHERE ip=? AND date=?", (ip, date)
        ).fetchone()
        return float(row[0]) if row else 0.0
    finally:
        conn.close()


def get_history(ip: str | None, start_date: str, end_date: str):
    """Return usage history rows for a date range.

    If ip is None, returns all devices.
    Returns: list of (date, ip, seconds, limit_val) tuples.
    """
    conn = sqlite3.connect(str(DB_PATH), timeout=5, isolation_level=None)
    try:
        conn.execute("PRAGMA journal_mode=WAL")
        if ip:
            rows = conn.execute(
                """SELECT d.date, d.ip, d.seconds, COALESCE(dev.limit_val, 300) as limit_val
                   FROM daily_usage d
                   LEFT JOIN devices dev ON d.ip = dev.ip
                   WHERE d.date >= ? AND d.date <= ? AND d.ip = ?
                   ORDER BY d.date DESC""",
                (start_date, end_date, ip),
            ).fetchall()
        else:
            rows = conn.execute(
                """SELECT d.date, d.ip, d.seconds, COALESCE(dev.limit_val, 300) as limit_val
                   FROM daily_usage d
                   LEFT JOIN devices dev ON d.ip = dev.ip
                   WHERE d.date >= ? AND d.date <= ?
                   ORDER BY d.date DESC""",
                (start_date, end_date),
            ).fetchall()
        return rows
    finally:
        conn.close()


# ---------------------------------------------------------------------------
# Reset
# ---------------------------------------------------------------------------

def reset_device(ip: str) -> None:
    """Reset all usage records for a specific device."""
    with _db() as conn:
        conn.execute("DELETE FROM daily_usage WHERE ip=?", (ip,))


def reset_all() -> None:
    """Reset all usage records for all devices."""
    with _db() as conn:
        conn.execute("DELETE FROM daily_usage")


path: src/clawshorts/__init__.py
content: """ClawShorts - Multi-device YouTube Shorts blocker for Fire TV."""
from clawshorts.config import ConfigError
from clawshorts.device import Device

__version__ = "0.4.0"

__all__ = [
    "ConfigError",
    "Device",
]


path: src/clawshorts/cli.py
content: """CLI commands for ClawShorts multi-device management."""
from __future__ import annotations

__all__ = ["main", "create_parser"]

import argparse
import logging
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any

from clawshorts import config
from clawshorts.device import Device
from clawshorts.device_monitor import check_daemon, poll_screen

# Import the shared DB module (lives under scripts/)
_scripts_dir = Path(__file__).resolve().parent.parent.parent / "scripts"
sys.path.insert(0, str(_scripts_dir))
try:
    import clawshorts_db as db  # noqa: E402
    _HAS_DB = True
except ImportError:
    _HAS_DB = False

# Config key → display name mapping
_CONFIG_KEY_LABELS = {
    "shorts_width_threshold": "width_threshold",
    "shorts_max_aspect_ratio": "max_aspect_ratio",
    "shorts_fallback_height_ratio": "fallback_height_ratio",
    "shorts_delta_cap": "delta_cap",
    "default_screen_width": "screen_width",
    "default_screen_height": "screen_height",
}

ADB_PORT = 5555
ADB_TIMEOUT = 8

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_setup(args: argparse.Namespace) -> int:
    """First-time setup - add first device and initialize config."""
    print("=" * 40)
    print("  ⚡ ClawShorts Setup")
    print("=" * 40)
    print()

    valid, error = config.validate_device_input(args.ip, args.name)
    if not valid:
        print(f"  ❌ {error}")
        return 1

    try:
        device = config.add_device(args.ip, args.name, args.limit)
        print(f"  ✅ Device added: {device.name} ({device.ip})")
        print(f"  📊 Daily limit: {device.limit} shorts")

        # Auto-detect screen resolution
        if _HAS_DB:
            res = detect_screen_resolution(args.ip)
            if res:
                width, height = res
                print(f"  🖥️  Screen auto-detected: {width}x{height}")
                db.update_device_screen(args.ip, width, height)
            else:
                gbl_w = db.get_config(db.KEY_DEFAULT_SCREEN_WIDTH) or 1920
                gbl_h = db.get_config(db.KEY_DEFAULT_SCREEN_HEIGHT) or 1080
                print(f"  ⚠️  Screen not detected (ADB unavailable). Using global default: {int(gbl_w)}x{int(gbl_h)}")

        print()
        print("  Next steps:")
        print("    1. Enable ADB on Fire TV: Settings → My Fire TV → Developer Options → ADB Debugging")
        print("    2. Run: clawshorts connect")
        print("    3. Run: clawshorts start")
        return 0
    except config.ConfigError as e:
        print(f"  ❌ {e}")
        return 1


def cmd_add(args: argparse.Namespace) -> int:
    """Add a new device."""
    valid, error = config.validate_device_input(args.ip, args.name)
    if not valid:
        print(f"Error: {error}")
        return 1

    try:
        device = config.add_device(args.ip, args.name, args.limit)
        print(f"✅ Added: {device.name} ({device.ip})")

        # Auto-detect screen resolution
        if _HAS_DB:
            res = detect_screen_resolution(args.ip)
            if res:
                width, height = res
                print(f"🖥️  Screen auto-detected: {width}x{height}")
                db.update_device_screen(args.ip, width, height)
            else:
                gbl_w = db.get_config(db.KEY_DEFAULT_SCREEN_WIDTH) or 1920
                gbl_h = db.get_config(db.KEY_DEFAULT_SCREEN_HEIGHT) or 1080
                print(f"⚠️  Screen not detected (ADB unavailable). Using global default: {int(gbl_w)}x{int(gbl_h)}")

        return 0
    except config.ConfigError as e:
        print(f"Error: {e}")
        return 1


def cmd_remove(args: argparse.Namespace) -> int:
    """Remove a device by IP."""
    removed = config.remove_device(args.ip)
    if removed:
        print(f"✅ Removed device: {args.ip}")
        return 0
    print(f"Device not found: {args.ip}")
    return 1


def cmd_list(args: argparse.Namespace) -> int:
    """List all configured devices with effective config."""
    devices = config.load_devices()

    print("=" * 50)
    print("  📺 ClawShorts Devices")
    print("=" * 50)

    if not devices:
        print("  No devices configured.")
        print()
        print("  Quick start:")
        print("    clawshorts setup 192.168.1.100 living-room")
        print("=" * 50)
        return 0

    # Load DB config for effective values
    global_cfg = {}
    if _HAS_DB:
        try:
            db.init_db()
            global_cfg = {k: db.get_config(k) for k in db.CONFIG_KEYS}
        except Exception:
            pass

    for device in devices:
        status_icon = "🟢" if device.enabled else "🔴"
        print(f"  {status_icon} {device.name}")
        print(f"     IP: {device.ip} | Limit: {device.limit}/day")

        # Per-device effective config
        dev_full = None
        if _HAS_DB:
            try:
                dev_full = db.get_device(device.ip)
            except Exception:
                pass

        if dev_full and global_cfg:
            sw = dev_full.get("screen_width")
            sh = dev_full.get("screen_height")
            if sw and sh:
                print(f"     Screen: {sw}x{sh}", end="")
            else:
                print(f"     Screen: ?", end="")
        else:
            print(f"     Screen: ?", end="")

        # Show detection thresholds with source
        threshold_keys = [
            ("width_threshold", "shorts_width_threshold", "w"),
            ("max_aspect_ratio", "shorts_max_aspect_ratio", "ar"),
            ("fallback_height_ratio", "shorts_fallback_height_ratio", "h"),
            ("delta_cap", "shorts_delta_cap", "cap"),
        ]
        parts = []
        for col, cfg_key, abbrev in threshold_keys:
            dev_val = (dev_full or {}).get(col)
            gbl_val = global_cfg.get(cfg_key)
            default = db.DEFAULTS.get(cfg_key, 0) if _HAS_DB else 0
            eff_val, source = _effective_value(dev_val, gbl_val, default)
            mark = "*" if source == "per-device" else ""
            parts.append(f"{abbrev}={eff_val:.2f}{mark}")

        if parts:
            print(f" | {' | '.join(parts)}")

        print()

    print("=" * 50)
    print("  (* = per-device override, no mark = global default)")
    return 0


def cmd_status(args: argparse.Namespace) -> int:
    """Show quota status for device(s) with live verification."""
    devices = config.load_devices()
    if not devices:
        print("No devices configured. Run: clawshorts setup <IP>")
        return 1

    if args.ip:
        device = config.get_device(args.ip)
        if not device:
            print(f"Device not found: {args.ip}")
            return 1
        devices = [device]

    print("=" * 50)
    print("  📊 ClawShorts Status")
    print("=" * 50)

    for device in devices:
        daemon_health = check_daemon(device.ip)
        screen = poll_screen(device.ip)

        print(f"\n  📺 {device.name} ({device.ip})")
        print(f"  ────────────────────────────")
        print(f"  Daemon:  {daemon_health.status}")
        print(f"  Detail:  {daemon_health.detail}")
        print(f"  App:     {screen.app}")
        print(f"  Screen:  {screen.detail}")

        if _HAS_DB:
            usage = db.get_seconds(device.ip, time.strftime("%Y-%m-%d"))
            remaining = max(0.0, device.limit - usage)
            print(f"  Usage:   {usage:.0f}s / {device.limit}s ({remaining:.0f}s remaining)")

        print(f"  Status:  {'Enabled' if device.enabled else 'Disabled'}")

    print("\n" + "=" * 50)
    return 0


def cmd_reset(args: argparse.Namespace) -> int:
    """Reset quota for device(s) via SQLite only."""
    if args.ip:
        ips = [args.ip]
    else:
        devices = config.load_devices()
        if not devices:
            print("No devices found to reset.")
            return 1
        ips = [d.ip for d in devices]

    if not ips:
        print("No devices found to reset.")
        return 1

    if _HAS_DB:
        try:
            db.init_db()
            if args.ip:
                db.reset_device(args.ip)
            else:
                db.reset_all()
        except OSError as e:
            logger.warning("DB reset failed: %s", e)

    for ip in ips:
        print(f"  ✅ Reset quota for {ip}")

    print(f"\n  {len(ips)} device(s) reset. Counts will restart from 0.")
    return 0


def cmd_enable(args: argparse.Namespace) -> int:
    """Enable a device."""
    device = config.update_device(args.ip, enabled=True)
    if device:
        print(f"✅ Enabled: {device.name}")
        return 0
    print(f"Device not found: {args.ip}")
    return 1


def cmd_disable(args: argparse.Namespace) -> int:
    """Disable a device."""
    device = config.update_device(args.ip, enabled=False)
    if device:
        print(f"✅ Disabled: {device.name}")
        return 0
    print(f"Device not found: {args.ip}")
    return 1


def cmd_connect(args: argparse.Namespace) -> int:
    """Guide user through ADB connection steps and re-detect screen."""
    if not args.ip:
        print("Usage: shorts connect <IP>")
        return 1

    print("📱 ADB Connection Steps:")
    print("")
    print(f"1. Connect to {args.ip}:")
    print(f"   adb connect {args.ip}")
    print("")
    print("   If successful, you should see: 'connected to {args.ip}:5555'")
    print("")
    print("2. Enable ADB Debugging on your Fire TV:")
    print("   Settings → My Fire TV → Developer Options → ADB Debugging = ON")
    print("")
    print("3. Make sure your Fire TV and Mac are on the same WiFi network.")
    print("")

    # Re-detect screen resolution after connecting
    if _HAS_DB:
        print("🔍 Detecting screen resolution...")
        res = detect_screen_resolution(args.ip)
        if res:
            width, height = res
            print(f"   ✅ Detected: {width}x{height}")
            db.update_device_screen(args.ip, width, height)
        else:
            print(f"   ⚠️  Could not detect screen. ADB may not be connected.")
            print(f"      Run 'adb connect {args.ip}' first, then try again.")

    print("")
    print("Once connected, ClawShorts will automatically detect Shorts viewing.")
    return 0


def cmd_history(args: argparse.Namespace) -> int:
    """Show watch history from the DB."""
    if not _HAS_DB:
        print("❌ Database not available")
        return 1

    try:
        import datetime
        today = datetime.date.today()
        start_date = today - datetime.timedelta(days=args.days)

        rows = db.get_history(args.ip, start_date.isoformat(), today.isoformat())

        if not rows:
            print(f"No watch history in the last {args.days} days.")
            return 0

        print(f"{'📅 Date':<14} {'Device':<20} {'Seconds':>10} {'Limit':>8} {'Status'}")
        print("─" * 70)

        for row in rows:
            date, ip, seconds, limit_val = row
            remaining = max(0.0, limit_val - seconds)
            if seconds >= limit_val:
                status = "🚫 Limit reached"
            elif remaining < limit_val * 0.2:
                status = "⚠️  Almost out"
            else:
                status = "✅ Under limit"
            print(f"{date:<14} {ip:<20} {seconds:>10.0f} {limit_val:>8} {status}")

        print(f"\n📊 Showing last {args.days} days")
        return 0
    except Exception as e:
        print(f"❌ Error fetching history: {e}")
        return 1


def cmd_logs(args: argparse.Namespace) -> int:
    """Show debug logs."""
    log_path = Path.home() / ".clawshorts" / "daemon.log"
    if not log_path.exists():
        print("No daemon.log found.")
        return 1

    lines = log_path.read_text(errors="replace").splitlines()
    tail = lines[-args.lines:] if len(lines) > args.lines else lines

    for line in tail:
        print(line)
    return 0


# ---------------------------------------------------------------------------
# Screen detection
# ---------------------------------------------------------------------------

def detect_screen_resolution(ip: str) -> tuple[int, int] | None:
    """Query screen size via ADB. Returns (width, height) or None on failure."""
    dev = f"{ip}:{ADB_PORT}"
    try:
        result = subprocess.run(
            ["adb", "-s", dev, "shell", "dumpsys", "display"],
            capture_output=True, text=True, timeout=ADB_TIMEOUT,
        )
        out = result.stdout
        # Parse mDisplayWidth / mDisplayHeight
        w_m = re.search(r"mDisplayWidth=(\d+)", out)
        h_m = re.search(r"mDisplayHeight=(\d+)", out)
        if w_m and h_m:
            return int(w_m.group(1)), int(h_m.group(1))
        # Fallback: wm size
        result2 = subprocess.run(
            ["adb", "-s", dev, "shell", "wm", "size"],
            capture_output=True, text=True, timeout=ADB_TIMEOUT,
        )
        m = re.search(r"(\d+)x(\d+)", result2.stdout)
        if m:
            return int(m.group(1)), int(m.group(2))
        return None
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
        return None


def cmd_detect(args: argparse.Namespace) -> int:
    """Re-detect screen resolution via ADB and update DB."""
    print(f"🔍 Detecting screen resolution for {args.ip}...")
    result = detect_screen_resolution(args.ip)
    if result is None:
        print(f"❌ Could not detect screen for {args.ip}. Is ADB connected?")
        # Fall back to global defaults
        if _HAS_DB:
            w = db.get_config(db.KEY_DEFAULT_SCREEN_WIDTH) or 1920
            h = db.get_config(db.KEY_DEFAULT_SCREEN_HEIGHT) or 1080
            print(f"   Using global defaults: {int(w)}x{int(h)}")
        return 1

    width, height = result
    print(f"✅ Detected: {width}x{height}")
    if _HAS_DB:
        db.update_device_screen(args.ip, width, height)
        print(f"   Updated DB for {args.ip}")
    return 0


# ---------------------------------------------------------------------------
# Config commands
# ---------------------------------------------------------------------------

def _effective_value(device_val, global_val, default):
    """Return human-readable (value, source) for a config value."""
    if device_val is not None:
        return float(device_val), "per-device"
    if global_val is not None:
        return float(global_val), "global"
    return default, "hardcoded"


def cmd_config(args: argparse.Namespace) -> int:
    """Show / get / set global or per-device config values."""
    if not _HAS_DB:
        print("❌ Database not available")
        return 1

    try:
        db.init_db()
    except Exception as e:
        print(f"❌ DB error: {e}")
        return 1

    sub = args.config_sub

    # Bare `shorts config` → show global defaults
    if sub is None:
        sub = "show"

    if sub == "show":
        # `shorts config show [IP]` — show all keys with effective values
        ip = getattr(args, "ip", None)
        global_cfg = {k: db.get_config(k) for k in db.CONFIG_KEYS}
        global_cfg = {k: v for k, v in global_cfg.items() if v is not None}

        print("=" * 50)
        print("  ⚙️  ClawShorts Config")
        print("=" * 50)
        print("\n  Global defaults:")
        for key in db.CONFIG_KEYS:
            label = _CONFIG_KEY_LABELS.get(key, key)
            val = global_cfg.get(key)
            default = db.DEFAULTS.get(key)
            display_val = f"{val:.4f}" if val is not None else f"{default:.4f} (hardcoded)"
            print(f"    {label:<28} {display_val}")

        if ip:
            dev = db.get_device(ip)
            if dev:
                print(f"\n  Device: {ip} ({dev.get('name', 'unknown')})")
                for key in db.CONFIG_KEYS:
                    label = _CONFIG_KEY_LABELS.get(key, key)
                    col = db.KEY_TO_DEVICE_COLUMN.get(key)
                    dev_val = dev.get(col) if col else None
                    gbl_val = global_cfg.get(key)
                    default = db.DEFAULTS.get(key, 0)
                    eff_val, source = _effective_value(dev_val, gbl_val, default)
                    override_mark = " *" if source == "per-device" else ""
                    print(f"    {label:<28} {eff_val:.4f}{override_mark}  ({source})")
            else:
                print(f"\n  Device {ip} not found.")
        print()
        print("  (* = per-device override)")
        return 0

    if sub == "get":
        # `shorts config get <key>`
        key = getattr(args, "key", None)
        if not key:
            print("Usage: shorts config get <key>")
            return 1
        # Normalize key
        norm_key = _normalize_key(key)
        if norm_key is None:
            print(f"Unknown config key: {key}")
            print(f"Available: {', '.join(_CONFIG_KEY_LABELS.values())}")
            return 1
        val = db.get_config(norm_key)
        if val is None:
            val = db.DEFAULTS.get(norm_key, 0)
            print(f"{_CONFIG_KEY_LABELS[norm_key]}: {val:.4f} (hardcoded default)")
        else:
            print(f"{_CONFIG_KEY_LABELS[norm_key]}: {val:.4f}")
        return 0

    if sub == "set":
        # `shorts config set <key> <value>` OR `shorts config set <IP> <key> <value>`
        target = getattr(args, "target", None)
        key = getattr(args, "key", None)
        value = getattr(args, "value", None)

        if value is None:
            # 3-arg form: `shorts config set <key> <value>` → global
            value = float(key)
            key = target
            target = None
            norm_key = _normalize_key(key)
            if norm_key is None:
                print(f"Unknown config key: {key}")
                return 1
            db.set_config(norm_key, value)
            print(f"✅ Set global {_CONFIG_KEY_LABELS.get(norm_key, norm_key)} = {value}")
        else:
            # 4-arg form: `shorts config set <IP> <key> <value>` → per-device
            ip = target
            norm_key = _normalize_key(key)
            if norm_key is None:
                print(f"Unknown config key: {key}")
                return 1
            col = db.KEY_TO_DEVICE_COLUMN.get(norm_key)
            if col is None:
                print(f"Key {key} cannot be set per-device (no device column).")
                return 1
            dev = db.get_device(ip)
            if not dev:
                print(f"Device not found: {ip}")
                return 1
            db.update_device(ip, **{col: float(value)})
            print(f"✅ Set {ip} {_CONFIG_KEY_LABELS.get(norm_key, norm_key)} = {value}")
        return 0

    if sub == "reset":
        # `shorts config reset [IP]` — clear per-device overrides
        ip = getattr(args, "ip", None)
        if not ip:
            print("Usage: shorts config reset <IP>")
            return 1
        dev = db.get_device(ip)
        if not dev:
            print(f"Device not found: {ip}")
            return 1
        db.reset_device_config(ip)
        print(f"✅ Reset per-device config for {ip} → using global defaults")
        return 0

    # default: just show help
    print("Usage: shorts config [show|get|set|reset] ...")
    return 0


def _normalize_key(key: str) -> str | None:
    """Normalize a user-facing key name to the internal config key."""
    # Direct mapping of display names
    DISPLAY_TO_KEY = {v: k for k, v in _CONFIG_KEY_LABELS.items()}
    # Also accept the full internal names
    DISPLAY_TO_KEY.update({k: k for k in db.CONFIG_KEYS})
    return DISPLAY_TO_KEY.get(key)


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

def create_parser() -> argparse.ArgumentParser:
    """Create the argument parser."""
    parser = argparse.ArgumentParser(
        prog="clawshorts",
        description="⚡ ClawShorts - YouTube Shorts Blocker for Fire TV",
    )
    parser.add_argument("--debug", "-d", action="store_true", help="Enable debug logging")
    parser.add_argument("--config", "-c", type=Path, help="Path to config file")

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # setup
    p = subparsers.add_parser("setup", help="First-time setup")
    p.add_argument("ip", help="Fire TV IP address")
    p.add_argument("name", nargs="?", help="Device name (optional)")
    p.add_argument("--limit", "-l", type=int, default=300, help="Daily shorts limit (default: 300s)")
    p.set_defaults(func=cmd_setup)

    # add
    p = subparsers.add_parser("add", help="Add a new device")
    p.add_argument("ip", help="Fire TV IP address")
    p.add_argument("name", nargs="?", help="Device name (optional)")
    p.add_argument("--limit", "-l", type=int, default=300, help="Daily shorts limit (default: 300s)")
    p.set_defaults(func=cmd_add)

    # remove
    p = subparsers.add_parser("remove", help="Remove a device")
    p.add_argument("ip", help="Fire TV IP address")
    p.set_defaults(func=cmd_remove)

    # list
    subparsers.add_parser("list", help="List all devices").set_defaults(func=cmd_list)

    # status
    p = subparsers.add_parser("status", help="Show quota status")
    p.add_argument("ip", nargs="?", help="Device IP (optional, shows all if omitted)")
    p.set_defaults(func=cmd_status)

    # reset
    p = subparsers.add_parser("reset", help="Reset quota")
    p.add_argument("ip", nargs="?", help="Device IP (optional, resets all if omitted)")
    p.set_defaults(func=cmd_reset)

    # enable
    p = subparsers.add_parser("enable", help="Enable a device")
    p.add_argument("ip", help="Fire TV IP address")
    p.set_defaults(func=cmd_enable)

    # disable
    p = subparsers.add_parser("disable", help="Disable a device")
    p.add_argument("ip", help="Fire TV IP address")
    p.set_defaults(func=cmd_disable)

    # connect
    p = subparsers.add_parser("connect", help="Guide for ADB connection")
    p.add_argument("ip", nargs="?", help="Fire TV IP address (optional)")
    p.set_defaults(func=cmd_connect)

    # history
    p = subparsers.add_parser("history", help="Show watch history")
    p.add_argument("--days", "-d", type=int, default=30, help="Number of days to show (default: 30)")
    p.add_argument("ip", nargs="?", help="Device IP (optional)")
    p.set_defaults(func=cmd_history)

    # logs
    p = subparsers.add_parser("logs", help="Show debug logs")
    p.add_argument("--lines", "-n", type=int, default=50, help="Number of lines to show (default: 50)")
    p.set_defaults(func=cmd_logs)

    # config
    p = subparsers.add_parser("config", help="Show/get/set global or per-device config")
    sp = p.add_subparsers(dest="config_sub", help="Config sub-commands")

    # config show [IP]
    ps = sp.add_parser("show", help="Show all config keys (global defaults, or per-device effective if IP given)")
    ps.add_argument("ip", nargs="?", help="Device IP (optional)")
    ps.set_defaults(func=cmd_config)

    # config get <key>
    pg = sp.add_parser("get", help="Get a specific global config value")
    pg.add_argument("key", help="Config key (e.g. width_threshold, max_aspect_ratio)")
    pg.set_defaults(func=cmd_config)

    # config set <key> <value>   OR   config set <IP> <key> <value>
    ps_set = sp.add_parser("set", help="Set a global or per-device config value")
    ps_set.add_argument("target", help="IP address (for per-device) or config key (for global)")
    ps_set.add_argument("key", nargs="?", help="Config key (if target is IP)")
    ps_set.add_argument("value", nargs="?", help="Value (if target is IP, otherwise this is the value)")
    ps_set.set_defaults(func=cmd_config)

    # config reset <IP>
    pr = sp.add_parser("reset", help="Clear per-device overrides → use global defaults")
    pr.add_argument("ip", help="Device IP")
    pr.set_defaults(func=cmd_config)

    p.set_defaults(func=cmd_config)

    # detect
    p = subparsers.add_parser("detect", help="Re-detect screen resolution via ADB and update DB")
    p.add_argument("ip", help="Fire TV IP address")
    p.set_defaults(func=cmd_detect)

    return parser


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main(argv: list[str] | None = None) -> int:
    parser = create_parser()
    args = parser.parse_args(argv)

    log_level = "DEBUG" if args.debug else "INFO"
    config.configure_logging(log_level)

    if not args.command:
        parser.print_help()
        print()
        print("Quick Start:")
        print("  clawshorts setup 192.168.1.100 living-room")
        print("  clawshorts list")
        print("  clawshorts status")
        return 0

    try:
        return args.func(args) or 0
    except config.ConfigError as e:
        logger.error("Config error: %s", e)
        print(f"Error: {e}")
        return 1
    except Exception as e:
        logger.exception("Unexpected error: %s", e)
        print(f"Unexpected error: {e}")
        return 1


if __name__ == "__main__":
    sys.exit(main())


path: src/clawshorts/config.py
content: """Device configuration management — backed by SQLite.

Delegates all device CRUD to clawshorts_db (scripts/).
Preserves the same public API for backward compatibility with cli.py.
"""
from __future__ import annotations

import logging
import os
import sys
from pathlib import Path
from typing import Any

# Import db from scripts/ directory
_scripts_dir = Path(__file__).resolve().parent.parent.parent / "scripts"
sys.path.insert(0, str(_scripts_dir))
try:
    import clawshorts_db as db
except ImportError:
    raise ImportError("clawshorts_db not found — ensure scripts/ is accessible")

from .constants import STATE_DIR
from .device import Device
from .validators import validate_ipv4

__all__ = [
    "ConfigError",
    "add_device",
    "configure_logging",
    "get_config_path",
    "get_device",
    "load_devices",
    "remove_device",
    "save_devices",
    "set_config_path",
    "update_device",
    "validate_device_input",
]

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

def configure_logging(level: str = "INFO") -> None:
    logging.basicConfig(
        level=getattr(logging, level.upper(), logging.INFO),
        format="[%(asctime)s] %(levelname)s [%(name)s] %(message)s",
        datefmt="%H:%M:%S",
    )


# ---------------------------------------------------------------------------
# Config paths (kept for compat)
# ---------------------------------------------------------------------------

def get_config_path() -> Path:
    return _CONFIG_FILE


def set_config_path(path: Path) -> None:
    global _CONFIG_FILE
    _CONFIG_FILE = Path(path)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------

class ConfigError(Exception):
    pass


# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------

def validate_device_input(ip: str, name: str | None = None) -> tuple[bool, str]:
    """Validate device input. Returns (ok, error_message)."""
    if not validate_ipv4(ip):
        return False, f"Invalid IP format: {ip}"
    if name:
        import re
        if not re.match(r"^[a-zA-Z0-9\-_]+$", name):
            return False, "Name can only contain alphanumeric, hyphen, underscore"
        if len(name) > 50:
            return False, "Name must be 50 characters or less"
    return True, ""


# ---------------------------------------------------------------------------
# Public API — delegates to clawshorts_db
# ---------------------------------------------------------------------------

def load_devices() -> list[Device]:
    """Load all enabled devices from SQLite."""
    db_devices = db.get_devices()
    return [_row_to_device(d) for d in db_devices]


def get_device(ip: str) -> Device | None:
    """Get a single device by IP."""
    d = db.get_device(ip)
    return _row_to_device(d) if d else None


def add_device(ip: str, name: str | None = None, limit: int = 300) -> Device:
    """Add or replace a device in SQLite."""
    db.add_device(ip, name, float(limit))
    return Device(ip=ip, name=name, limit=limit)


def remove_device(ip: str) -> bool:
    """Remove a device (soft-disable)."""
    return db.remove_device(ip)


def update_device(ip: str, **kwargs: Any) -> Device | None:
    """Update device fields."""
    result = db.update_device(ip, **kwargs)
    if result:
        return _row_to_device(result)
    return None


def save_devices(devices: list[Device]) -> None:
    """Replace all devices. For compat only — prefer direct DB calls."""
    for d in devices:
        db.add_device(d.ip, d.name, float(d.limit))


# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _row_to_device(row: dict) -> Device:
    """Convert a DB row dict to a Device model."""
    return Device(
        ip=row["ip"],
        name=row.get("name"),
        limit=int(row["limit_val"]),
        enabled=bool(row.get("enabled", True)),
    )


path: src/clawshorts/constants.py
content: """Shared constants for ClawShorts."""
from __future__ import annotations

from pathlib import Path

__all__ = [
    "STATE_DIR",
    "CONFIG_DIR",
    "DEFAULT_SCREEN_WIDTH",
    "DEFAULT_SCREEN_HEIGHT",
    "MAX_DELTA_SECONDS",
]

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

STATE_DIR = Path.home() / ".clawshorts"
CONFIG_DIR = STATE_DIR

# ---------------------------------------------------------------------------
# Screen defaults (CLI-only fallback; daemon uses DB-loaded values)
# ---------------------------------------------------------------------------

DEFAULT_SCREEN_WIDTH = 1920
DEFAULT_SCREEN_HEIGHT = 1080

# ---------------------------------------------------------------------------
# Detection (fallback only — primary values come from DB config)
# ---------------------------------------------------------------------------

# Max gap (seconds) between consecutive Shorts detections that still counts
# as continuous watch time.
MAX_DELTA_SECONDS = 15.0


path: src/clawshorts/detection.py
content: """Shared Shorts detection logic.

Both device_monitor.py (for `shorts status`) and clawshorts-daemon.py
use this module for identical Shorts detection.
"""
from __future__ import annotations

import re
from typing import NamedTuple


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Width as fraction of screen width below which an element is considered Shorts
SHORTS_WIDTH_THRESHOLD = 0.45  # 45% — portrait-ish

# Max width/height ratio for Shorts (portrait-ish: taller than wide)
SHORTS_MAX_ASPECT_RATIO = 1.3


# ---------------------------------------------------------------------------
# Types
# ---------------------------------------------------------------------------

class ElementInfo(NamedTuple):
    """Parsed UI element information."""
    app: str
    width: int | None
    height: int | None
    detail: str


# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------

def is_shorts_element(width: int | None, height: int | None, screen_width: int) -> bool:
    """Return True if the given element dimensions match a Shorts video.

    Shorts videos are portrait-ish (taller than wide, or at least not wide).
    On Fire TV YouTube, Shorts appear as ~38-45% screen width.
    Regular videos are ~100% width.

    Args:
        width: Element width in pixels (can be None if unknown)
        height: Element height in pixels (can be None if unknown)
        screen_width: Screen width in pixels for threshold calculation

    Returns:
        True if element dimensions match Shorts pattern
    """
    if width is None:
        return False

    # Width must be below the threshold to be Shorts
    width_ratio = width / screen_width if screen_width else 0
    if width_ratio >= SHORTS_WIDTH_THRESHOLD:
        return False

    # If height is known, check aspect ratio (portrait-ish)
    if height is not None and height > 0:
        # Shorts are taller than wide (aspect ratio < 1.0 typically,
        # but we use inverse so we check if it exceeds the max ratio)
        # Actually: short videos are wider than tall = w/h > 1.0
        # We want to exclude very wide 16:9 content
        if width / height > SHORTS_MAX_ASPECT_RATIO:
            return False

    return True


def parse_element_from_ui_dump(ui_text: str) -> ElementInfo | None:
    """Parse the focused element from a Fire TV UI dump.

    Extracts app, width, height, and detail from a uiautomator XML dump.

    Args:
        ui_text: Raw output from uiautomator dump

    Returns:
        ElementInfo or None if no focused element found
    """
    if not ui_text or "node" not in ui_text.lower():
        return None

    # Try to extract focused/larger element
    lines = ui_text.strip().split("\n")
    for line in lines:
        if 'bounds="' in line or 'bounds="' in line:
            # Extract bounds: bounds="[left,top][right,bottom]"
            bounds_m = re.search(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', line)
            if bounds_m:
                left, top, right, bottom = (
                    int(bounds_m.group(1)),
                    int(bounds_m.group(2)),
                    int(bounds_m.group(3)),
                    int(bounds_m.group(4)),
                )
                width = right - left
                height = bottom - top
            else:
                width, height = None, None

            # Extract package/app
            package_m = re.search(r'package="([^"]+)"', line)
            app = package_m.group(1) if package_m else "unknown"

            # Extract text or content-desc for detail
            text_m = re.search(r'text="([^"]*)"', line)
            content_m = re.search(r'content-desc="([^"]*)"', line)
            detail = text_m.group(1) or content_m.group(1) or ""

            return ElementInfo(app=app, width=width, height=height, detail=detail)

    return None


path: src/clawshorts/device.py
content: """Device model — re-exported from unified models module."""
from __future__ import annotations

from .models import Device

__all__ = ["Device"]


path: src/clawshorts/device_monitor.py
content: """Device monitoring helpers — extracted from cmd_status."""
from __future__ import annotations

import logging
import re
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path

from .constants import DEFAULT_SCREEN_WIDTH
from .detection import is_shorts_element, parse_element_from_ui_dump

__all__ = ["DaemonHealth", "ScreenInfo", "check_daemon", "poll_screen"]

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------

@dataclass
class DaemonHealth:
    healthy: bool
    status: str
    detail: str


@dataclass
class ScreenInfo:
    app: str
    detail: str


# ---------------------------------------------------------------------------
# Daemon health check
# ---------------------------------------------------------------------------

def check_daemon(ip: str) -> DaemonHealth:
    """Check if the daemon is running and doing UI polls.

    Returns (healthy, status, detail).
    """
    try:
        result = subprocess.run(
            ["pgrep", "-f", "clawshorts-daemon.py"],
            capture_output=True,
            text=True,
            timeout=5,
        )
        pids = [p for p in result.stdout.strip().split("\n") if p]
        if not pids:
            return DaemonHealth(False, "🔴 Daemon not running", "Run 'shorts start' to start")
    except subprocess.TimeoutExpired:
        return DaemonHealth(False, "🔴 Process check timed out", "Check ADB connectivity")
    except FileNotFoundError:
        log.warning("pgrep not available")
        return DaemonHealth(False, "🔴 pgrep not found", "Install procps")
    except OSError as e:
        log.warning("Process check failed: %s", e)
        return DaemonHealth(False, "🔴 Process check failed", str(e)[:50])

    # Check log for recent activity
    log_file = Path.home() / ".clawshorts" / "daemon.log"
    if log_file.exists():
        try:
            content = log_file.read_text(errors="replace")
            today = time.strftime("%Y-%m-%d")
            lines = content.strip().split("\n")
            recent = [
                l for l in lines
                if l.startswith(f"[{today}]") or "HEARTBEAT" in l or "DEBUG" in l
            ]
            if recent:
                debug_lines = [l for l in recent if "DEBUG" in l or "Shorts:" in l]
                if debug_lines:
                    last_debug = debug_lines[-1]
                    ts_match = re.search(
                        r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]", last_debug
                    )
                    detail = last_debug.split("INFO")[-1].split("DEBUG")[-1].strip()[:50]
                    return DaemonHealth(True, "🟢 Daemon active", f"Polling: {detail}")
                return DaemonHealth(True, "🟢 Daemon active", "UI polling confirmed")
        except OSError as e:
            log.warning("Log read failed: %s", e)

    return DaemonHealth(True, "🟡 Daemon running", "No recent UI polls (may be idle)")


# ---------------------------------------------------------------------------
# Screen resolution detection
# ---------------------------------------------------------------------------

def _get_screen_width(ip: str) -> int:
    """Query actual screen width from device via adb, caching via simple file cache."""
    cache_file = Path.home() / ".clawshorts" / f"screen_{ip.replace('.', '_')}.txt"
    now = time.time()
    # Cache for 1 hour
    if cache_file.exists():
        try:
            content = cache_file.read_text().strip()
            age = now - cache_file.stat().st_mtime
            if age < 3600 and content:
                return int(content)
        except (ValueError, OSError):
            pass
    # Query device
    device_id = f"{ip}:5555"
    try:
        out = subprocess.run(
            ["adb", "-s", device_id, "shell", "wm", "size"],
            capture_output=True, text=True, timeout=6,
        )
        # Prefer override size (last match) over physical size (first match)
        matches = re.findall(r"(\d+)x(\d+)", out.stdout)
        if matches:
            # Use the LAST match (override takes priority over physical)
            last = matches[-1]
            width = int(last[0])
            cache_file.write_text(str(width))
            return width
    except (subprocess.TimeoutExpired, OSError):
        pass
    return DEFAULT_SCREEN_WIDTH


# ---------------------------------------------------------------------------
# Live screen poll
# ---------------------------------------------------------------------------

def poll_screen(ip: str) -> ScreenInfo:
    """Poll the device's current app and focused element.

    Returns (app_name, focused_info).
    """
    device_id = f"{ip}:5555"

    # Check foreground app
    try:
        result = subprocess.run(
            ["adb", "-s", device_id, "shell", "dumpsys", "activity", "activities"],
            capture_output=True,
            text=True,
            timeout=10,
        )
        youtube_active = any(
            pkg in result.stdout
            for pkg in [
                "com.amazon.firetv.youtube",
                "com.google.android.youtube.tv",
            ]
        )
        app = "YouTube" if youtube_active else "Other/Home"
    except subprocess.TimeoutExpired:
        return ScreenInfo("Error", "ADB command timed out")
    except FileNotFoundError:
        return ScreenInfo("Error", "ADB not installed")
    except OSError as e:
        return ScreenInfo("Error", f"ADB error: {e}"[:50])

    # Get focused element via UI dump
    try:
        subprocess.run(
            ["adb", "-s", device_id, "shell", "uiautomator", "dump", "/sdcard/ui.xml"],
            capture_output=True,
            timeout=15,
        )
        pull_result = subprocess.run(
            ["adb", "-s", device_id, "pull", "/sdcard/ui.xml", "/tmp/verify-ui.xml"],
            capture_output=True,
            timeout=10,
        )
        if pull_result.returncode == 0:
            xml_content = Path("/tmp/verify-ui.xml").read_text(errors="replace")
            elem = parse_element_from_ui_dump(xml_content)
            if elem and elem.width:
                screen_w = _get_screen_width(ip)
                w = elem.width
                pct = w / screen_w * 100
                is_shorts = is_shorts_element(w, elem.height, screen_w)
                status = "Shorts" if is_shorts else "Regular video"
                return ScreenInfo(app, f"Focused: {w}px ({pct:.0f}%) - {status}")
            return ScreenInfo(app, "Focused: unknown")
        return ScreenInfo(app, "UI dump/pull failed")
    except subprocess.TimeoutExpired:
        return ScreenInfo(app, "UI dump timed out")
    except OSError as e:
        return ScreenInfo(app, f"UI dump error: {e}"[:50])


path: src/clawshorts/models.py
content: """Pydantic Device model for ClawShorts."""
from __future__ import annotations

import re

from pydantic import BaseModel, Field, field_validator, model_validator

__all__ = ["Device"]


class Device(BaseModel):
    """Fire TV device configuration."""

    ip: str
    name: str | None = Field(default=None)
    limit: int = Field(default=300, ge=0)
    enabled: bool = True

    model_config = {"frozen": False, "extra": "ignore"}

    @field_validator("ip")
    @classmethod
    def validate_ip(cls, v: str) -> str:
        if not re.match(r"^(\d{1,3}\.){3}\d{1,3}$", v):
            raise ValueError(f"Invalid IP format: {v}")
        if any(int(o) > 255 for o in v.split(".")):
            raise ValueError(f"Invalid IP (octet > 255): {v}")
        return v

    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str | None) -> str | None:
        if v is None:
            return v
        sanitized = re.sub(r"[^a-zA-Z0-9\-_]", "", v)
        if not sanitized:
            raise ValueError("Name cannot be only special characters")
        return sanitized[:50]

    @model_validator(mode="after")
    def auto_name(self) -> "Device":
        if not self.name:
            self.name = f"tv-{self.ip.replace('.', '-')}"
        return self

    def __str__(self) -> str:
        s = "on" if self.enabled else "off"
        return f"{self.name} ({self.ip}) {self.limit}/day [{s}]"


path: src/clawshorts/validators.py
content: """Shared validators for ClawShorts."""
from __future__ import annotations

import ipaddress
import re
from typing import Any

__all__ = ["validate_ipv4", "validate_ip", "ip_to_slug"]


def validate_ipv4(ip: str) -> bool:
    """Validate an IPv4 address is a reachable private IP.

    Returns True if the string is a valid dotted-quad IPv4 address
    where every octet is in the range 0-255 AND the address is a
    private (RFC 1918) or loopback address.

    Rejects public IPs to prevent accidentally targeting
    unrelated hosts on the internet.
    """
    if not isinstance(ip, str):
        return False
    try:
        addr = ipaddress.ip_address(ip)
        return addr.is_private or addr.is_loopback
    except ValueError:
        return False


def ip_to_slug(ip: str) -> str:
    """Convert an IP address to a safe slug for use in filenames.

    Example: "192.168.1.100" -> "192-168-1-100"
    """
    return ip.replace(".", "-")


# Alias for backwards compatibility
validate_ip = validate_ipv4


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