diff --git a/.claude/skills/speediance/SKILL.md b/.claude/skills/speediance/SKILL.md new file mode 100644 index 0000000..225048c --- /dev/null +++ b/.claude/skills/speediance/SKILL.md @@ -0,0 +1,331 @@ +--- +name: speediance +description: > + Read completed workouts (summaries and full per-set detail), browse and export the + exercise catalog, and push custom training programs to your Speediance (Gym Monster) + smart cable machine via its cloud API. Authenticates with your account credentials, + caches a session token in your OS user-cache directory (override with SPEEDIANCE_TOKEN_CACHE), + and makes outbound HTTPS requests to the Speediance cloud API. Reads and emits structured + data β€” the caller decides where to store it. Ships as a single static binary β€” no Python or + other runtime required. +metadata: + openclaw: + emoji: πŸ‹οΈ + homepage: https://github.com/stozo04/speediance-cli + primaryEnv: SPEEDIANCE_EMAIL + permissions: + network: + - "Speediance cloud API (HTTPS) β€” authentication, workout history, exercise catalog, program creation" + files.read: + - "config.json β€” optional credential/config file (working directory, or your OS user-config dir)" + - ".env β€” optional env file in the working directory; only the documented SPEEDIANCE_* keys are read (parsed to a map, never exported into the process environment)" + - "token cache β€” cached session token in your OS user-cache dir by default (non-roaming); path overridable via SPEEDIANCE_TOKEN_CACHE or the token_cache_path config key. A legacy .token.json in the working directory is read once to migrate it." + - "plan JSON files passed to the push command" + files.write: + - "token cache β€” session token written after login and refreshed automatically; in your OS user-cache dir by default (non-roaming; override via SPEEDIANCE_TOKEN_CACHE or token_cache_path). A legacy .token.json in the working directory is relocated here and then removed." + - "library.json β€” exercise catalog dump (library command; every run writes the full catalog to --out, default library.json)" + - "config.json β€” written by the config set command (owner-only permissions)" + requires: + bins: [] + env: + - SPEEDIANCE_EMAIL + - SPEEDIANCE_PASSWORD + envVars: + - name: SPEEDIANCE_EMAIL + description: Your Speediance / Gym Monster account email address + required: true + - name: SPEEDIANCE_PASSWORD + description: "Your account password. Google/SSO users: set a password once in the Speediance app (Profile β†’ Settings) before using this skill." + required: true + - name: SPEEDIANCE_REGION + description: "API region β€” Global (default) or EU" + required: false + - name: SPEEDIANCE_DEVICE_TYPE + description: "Device type integer β€” 1 = Gym Monster v1 (default, tested). Gym Monster 2 is untested; try 2 if exercises look wrong." + required: false + - name: SPEEDIANCE_CONFIG + description: "Path to config.json (overrides discovery: working dir, then OS user-config dir)" + required: false + - name: SPEEDIANCE_TOKEN_CACHE + description: "Override the token cache file location (default: OS user-cache dir)" + required: false +--- + +# Speediance β€” Gym Monster CLI Skill + +Talk to your **Speediance (Gym Monster)** smart cable machine from any agent. Read +completed workouts and push custom programs that appear on the machine ready to run β€” +no app navigation mid-session. + +> **Unofficial** β€” reverse-engineered from the Android app. Personal use, your own +> account only. Built on the MIT-licensed `UnofficialSpeedianceWorkoutManager` (hbui3) +> and `speediance-influx` (gavinmcfall). +> +> Tested on **Gym Monster v1** (`SPEEDIANCE_DEVICE_TYPE=1`). GM2 is untested. + +## Setup (one time) + +`speediance-cli` is a single static binary β€” **no Python or other runtime needed**. + +Install it one of two ways: + +```bash +# A) Download a release binary for your OS/arch, extract, put it on your PATH: +# https://github.com/stozo04/speediance-cli/releases + +# B) Or build/install with Go (1.24+): +go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latest +``` + +Then authenticate: + +```bash +speediance-cli login # authenticates and caches a session token (run `config path` to see where) +``` + +## Credentials + +Set as environment variables β€” the CLI reads them automatically: + +| Variable | Required | Default | Notes | +|---|---|---|---| +| `SPEEDIANCE_EMAIL` | βœ“ | β€” | Account email | +| `SPEEDIANCE_PASSWORD` | βœ“ | β€” | Account password | +| `SPEEDIANCE_REGION` | β€” | `Global` | `Global` or `EU` | +| `SPEEDIANCE_DEVICE_TYPE` | β€” | `1` | `1` = Gym Monster v1 | + +Alternatively, write a `config.json` in the working directory (gitignored by the repo): + +```json +{ + "email": "you@example.com", + "password": "yourpassword", + "region": "Global" +} +``` + +You can also put these variables in a gitignored **`.env`** file in the working directory β€” the +`SPEEDIANCE_*` keys are read from it automatically (exported environment variables still take +precedence). Only those keys are read; the file is parsed into a map and any other keys are +ignored, so a stray `.env` can never inject unrelated variables into the process environment. + +## Commands + +### Read workouts + +```bash +speediance-cli today --json # every session today, fully resolved (any type) +speediance-cli today --date 2026-06-17 --json # …or a specific day (today | yesterday | YYYY-MM-DD) +speediance-cli workouts --days 7 --json # recent sessions (summaries, for picking) +speediance-cli session --json # full, verbatim detail for one session +``` + +**`today` is the one-shot, agent-friendly entry point.** When the client just says +"I did a workout," call `today` β€” you do **not** need to know whether it was a +program, free weights, or a rowing/ski session. The tool finds the day's session(s) +and returns each one fully resolved, as an array of the same `{training_id, kind, +info, detail}` shape that `session` emits. (`kind` is `"program"`, `"free"`, or +`""`.) + +Sample `workouts --json` output (a digest for picking; `kind` lets you filter): + +```json +[ + { + "training_id": 123456, + "title": "Upper Body", + "date": "2025-06-15", + "duration_secs": 2700, + "calories": 320, + "volume": 4200.0, + "type": "Strength", + "kind": "program" + } +] +``` + +`session --json` is **autonomous and a faithful, complete passthrough**. Given +only an id it figures out what the session was β€” a program/Coach session, free +weights, or a rowing/ski free session β€” and emits the verbatim Speediance payloads +under a uniform shape. The field names, nesting, and values are exactly what +Speediance returned (`leftWatts`, `forceControlScore`, `weights`, `leftBreakTimes`, +`totalDistance`, …); the CLI does not rename, reshape, compute, or fill gaps. + +`kind` tells you which namespace answered, so a type-agnostic consumer always reads +the same two fields: + +| `kind` | `info` | `detail` | +|---|---|---| +| `"program"` | `cttTrainingInfo` payload (incl. `completionRate`) | `cttTrainingInfoDetail` β€” per-exercise, per-rep arrays | +| `"free"` | `freeTraining` payload (totals: `totalCapacity`, `totalEnergy`, `totalDistance` for rowing/ski; `name` for guided sessions) | `freeTrainingDetail` β€” `[]` for a freestyle Free Lift, **populated** for a guided session (e.g. *Aerobic Rowing* β†’ per-interval `finishedReps` with `distance`/`pace`/`spm` + per-stroke traces) | +| `""` | `null` | `null` (no session found in either namespace) | + +> `kind:"free"` is the *free namespace*, not "freestyle". It spans both a +> freestyle **Free Lift** (no `info.name`, `detail: []`, aggregates only) and a +> **guided** free-namespace session (has `info.name`, often a populated `detail`) β€” +> guided cardio like **Aerobic Rowing** carries the full per-interval breakdown. +> Distinguish via `info.name` + whether `detail` has rows; don't assume `free` β‡’ empty. + +A program session: + +```json +{ + "training_id": 940759, + "kind": "program", + "info": { + "completionRate": 100.0 + // … the verbatim GET /app/trainingInfo/cttTrainingInfo/ data payload + }, + "detail": [ + { + "actionLibraryName": "Standing Dual-Handle Hammer Curl", + "maxWeight": 15.0, "maxWeightCount": 5, + "score": 16, "completionScore": 5, "forceControlScore": 4, + "bilateralBalanceScore": 4, "amplitudeStableScore": 3, "actionRating": 3, + "finishedReps": [ + { + "finishedCount": 14, "targetCount": 14, "capacity": 330.0, "leftRight": 0, + "trainingInfoDetail": { + "weights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "leftWeights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "rightWeights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "leftWatts": [41.65,51.84, "…"], + "rightWatts": [26.28,55.05, "…"], + "leftAmplitudes": [0.46,0.68, "…"] + } + } + ] + } + ] +} +``` + +Notes for consumers: + +- **Auto-detection is built in** β€” no caller knowledge of the session type is + needed. `session ` probes the program namespace and falls back to free; + `today` picks each session's probe order from the day's record list (which + carries the authoritative type β€” free-first for non-program types), with the + same fallback. The result shape is identical either way. +- **`weight` is never invented.** There is no synthesized per-set weight. For a + program, the real per-rep weights are in `trainingInfoDetail.weights[]` (already + per attachment, so a single-handle average is just their mean); a mid-set drop + (e.g. `15Γ—5 β†’ 10Γ—9`) is therefore visible. Average or summarize as you see fit. +- **Free-namespace detail varies.** A *freestyle* Free Lift records session-level + totals only (`info` aggregates, `detail: []`). A *guided* free-namespace session + does more: **Aerobic Rowing** fills `detail` with per-interval rows + (`distance`/`pace`/`spm`/`time`) and per-stroke rope-length traces. Always read + `detail` rather than assuming `kind:"free"` is empty. +- **Absence is preserved.** A field or array Speediance omits is omitted in the + output too (e.g. a sparse capture with only `weights`); nothing is back-filled. +- **Values are unvalidated passthrough.** Speediance's fields aren't guaranteed + internally consistent, so derive the metric you want from raw values rather than + trusting a single field β€” e.g. a rowing split is `distance / time`, not the + per-interval `pace` field (which is an instantaneous sample). The CLI never + "corrects" a value; that interpretation is yours. +- **Empty shape.** `info` is `object | null`; `detail` is `array | null`. These are + the verbatim endpoint payloads (never normalized), so treat **both `null` and + `[]`** as "no rows" β€” e.g. `if not detail`. In practice `detail` is a populated + array for `kind:"program"`, `[]` for a freestyle Free Lift, **populated** for a + guided free-namespace session (see above), and `null` only for `kind:""`. +- **No flag unlocks data** β€” the endpoints return it, so the CLI returns it. There + is no `--telemetry`. + +> A `trainingId` can identify *different* sessions in the program vs. free +> namespaces. Auto-detection prefers the program match; pass `--program` or +> `--free` to `session` to force a namespace when an id is ambiguous. + +### Browse the exercise catalog + +```bash +speediance-cli library --search "chest" --json # filter by name or muscle +speediance-cli library # save full catalog to library.json +``` + +Returns `[{id, name, muscle, tab}]`. The `id` is required for plan JSON. +Every run saves the full catalog to `--out` (default `library.json`) β€” `--search` +filters only the stdout view, it does not narrow the saved file. +A committed `library.json` snapshot ships with the repo (Gym Monster v1) for offline +browsing β€” regenerate with `speediance-cli library` to get the freshest catalog or a +different device's exercises. + +### Create a training program + +Author a plan JSON, then push it β€” the program appears on the machine immediately: + +```bash +speediance-cli push plan.json --dry-run # preview payload, no network write +speediance-cli push plan.json # create it on the account +``` + +**Plan JSON format:** + +```json +{ + "name": "Pull Day", + "exercises": [ + { + "id": 434, + "title": "Seated Dual-Handle Lat Pulldown", + "sets": [ + {"reps": 12, "weight": 20, "mode": 1, "rest": 75}, + {"reps": 10, "weight": 22, "mode": 1, "rest": 75}, + {"reps": 8, "weight": 25, "mode": 1, "rest": 90} + ] + }, + { + "id": 291, + "title": "Seated Row", + "sets": [ + {"reps": 12, "weight": 18, "mode": 1, "rest": 60} + ] + } + ] +} +``` + +| Field | Type | Notes | +|---|---|---| +| `id` | int | From `speediance-cli library` β€” IDs differ per account/device | +| `weight` | float | **Kilograms** | +| `mode` | int | 1=Standard, 2=Eccentric, 3=Isokinetic, 4=Constant, 5=Spotter | +| `rest` | int | Seconds between sets | + +### Storing what you read + +The CLI owns no log format. To keep a record of a session, pull it with `workouts --json` +and `session --json`, then write it wherever you keep data (a Markdown sheet, a +database, a notebook). The tool reads and emits structured data; the caller decides the +layout. + +## Full command reference + +| Command | What it does | `--json` | +|---|---|---| +| `login` | Authenticate and cache a session token | β€” | +| `workouts [--days N]` | List recent completed sessions | βœ“ | +| `today [--date D]` | Every session on a day, auto-resolved to type-correct detail (the one-shot entry point) | βœ“ | +| `session [--free\|--program]` | Full, verbatim detail for one session; auto-detects program/free/rowing | βœ“ | +| `library [--search X] [--out FILE]` | Dump or search exercise catalog | βœ“ | +| `push [--dry-run]` | Create a training program on the account | βœ“ | +| `config show\|set\|path` | Manage `config.json` | βœ“ (`show`) | +| `version` | Build metadata (also `--version`) | βœ“ | +| `completion ` | Shell completion (bash/zsh/fish/powershell) | β€” | + +## Conventions + +- **stdout is parseable** with `--json`; all human-readable hints go to **stderr**. +- **Exit codes**: `0` success, `2` authentication failure, non-zero for other errors. +- **Secrets**: `config.json`, `.token.json`, `.env` are gitignored β€” never commit them. +- **Token caching**: after the first login, the token is cached in your **OS user-cache + directory** (e.g. `%LocalAppData%\speediance\token.json` on Windows, `~/.cache/speediance/token.json` + on Linux, `~/Library/Caches/speediance/token.json` on macOS) and refreshed automatically on + expiry β€” *not* in the working directory (so it can't be swept into a commit) and *not* in the + roaming config dir (so a live credential isn't synced across machines). Override the location with + `SPEEDIANCE_TOKEN_CACHE` or the `token_cache_path` config key; run `speediance-cli config path` to + see where it resolved. A token left in a legacy `.token.json` by an older version is moved to the + per-user location on first run. +- **Dry-run first**: always use `--dry-run` before `push` when authoring new programs to + confirm exercise IDs resolved correctly. +- If an endpoint breaks after a Speediance app update, all API calls live in + `internal/api` β€” that's the single place to patch. diff --git a/.codex/skills/speediance/SKILL.md b/.codex/skills/speediance/SKILL.md new file mode 100644 index 0000000..225048c --- /dev/null +++ b/.codex/skills/speediance/SKILL.md @@ -0,0 +1,331 @@ +--- +name: speediance +description: > + Read completed workouts (summaries and full per-set detail), browse and export the + exercise catalog, and push custom training programs to your Speediance (Gym Monster) + smart cable machine via its cloud API. Authenticates with your account credentials, + caches a session token in your OS user-cache directory (override with SPEEDIANCE_TOKEN_CACHE), + and makes outbound HTTPS requests to the Speediance cloud API. Reads and emits structured + data β€” the caller decides where to store it. Ships as a single static binary β€” no Python or + other runtime required. +metadata: + openclaw: + emoji: πŸ‹οΈ + homepage: https://github.com/stozo04/speediance-cli + primaryEnv: SPEEDIANCE_EMAIL + permissions: + network: + - "Speediance cloud API (HTTPS) β€” authentication, workout history, exercise catalog, program creation" + files.read: + - "config.json β€” optional credential/config file (working directory, or your OS user-config dir)" + - ".env β€” optional env file in the working directory; only the documented SPEEDIANCE_* keys are read (parsed to a map, never exported into the process environment)" + - "token cache β€” cached session token in your OS user-cache dir by default (non-roaming); path overridable via SPEEDIANCE_TOKEN_CACHE or the token_cache_path config key. A legacy .token.json in the working directory is read once to migrate it." + - "plan JSON files passed to the push command" + files.write: + - "token cache β€” session token written after login and refreshed automatically; in your OS user-cache dir by default (non-roaming; override via SPEEDIANCE_TOKEN_CACHE or token_cache_path). A legacy .token.json in the working directory is relocated here and then removed." + - "library.json β€” exercise catalog dump (library command; every run writes the full catalog to --out, default library.json)" + - "config.json β€” written by the config set command (owner-only permissions)" + requires: + bins: [] + env: + - SPEEDIANCE_EMAIL + - SPEEDIANCE_PASSWORD + envVars: + - name: SPEEDIANCE_EMAIL + description: Your Speediance / Gym Monster account email address + required: true + - name: SPEEDIANCE_PASSWORD + description: "Your account password. Google/SSO users: set a password once in the Speediance app (Profile β†’ Settings) before using this skill." + required: true + - name: SPEEDIANCE_REGION + description: "API region β€” Global (default) or EU" + required: false + - name: SPEEDIANCE_DEVICE_TYPE + description: "Device type integer β€” 1 = Gym Monster v1 (default, tested). Gym Monster 2 is untested; try 2 if exercises look wrong." + required: false + - name: SPEEDIANCE_CONFIG + description: "Path to config.json (overrides discovery: working dir, then OS user-config dir)" + required: false + - name: SPEEDIANCE_TOKEN_CACHE + description: "Override the token cache file location (default: OS user-cache dir)" + required: false +--- + +# Speediance β€” Gym Monster CLI Skill + +Talk to your **Speediance (Gym Monster)** smart cable machine from any agent. Read +completed workouts and push custom programs that appear on the machine ready to run β€” +no app navigation mid-session. + +> **Unofficial** β€” reverse-engineered from the Android app. Personal use, your own +> account only. Built on the MIT-licensed `UnofficialSpeedianceWorkoutManager` (hbui3) +> and `speediance-influx` (gavinmcfall). +> +> Tested on **Gym Monster v1** (`SPEEDIANCE_DEVICE_TYPE=1`). GM2 is untested. + +## Setup (one time) + +`speediance-cli` is a single static binary β€” **no Python or other runtime needed**. + +Install it one of two ways: + +```bash +# A) Download a release binary for your OS/arch, extract, put it on your PATH: +# https://github.com/stozo04/speediance-cli/releases + +# B) Or build/install with Go (1.24+): +go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latest +``` + +Then authenticate: + +```bash +speediance-cli login # authenticates and caches a session token (run `config path` to see where) +``` + +## Credentials + +Set as environment variables β€” the CLI reads them automatically: + +| Variable | Required | Default | Notes | +|---|---|---|---| +| `SPEEDIANCE_EMAIL` | βœ“ | β€” | Account email | +| `SPEEDIANCE_PASSWORD` | βœ“ | β€” | Account password | +| `SPEEDIANCE_REGION` | β€” | `Global` | `Global` or `EU` | +| `SPEEDIANCE_DEVICE_TYPE` | β€” | `1` | `1` = Gym Monster v1 | + +Alternatively, write a `config.json` in the working directory (gitignored by the repo): + +```json +{ + "email": "you@example.com", + "password": "yourpassword", + "region": "Global" +} +``` + +You can also put these variables in a gitignored **`.env`** file in the working directory β€” the +`SPEEDIANCE_*` keys are read from it automatically (exported environment variables still take +precedence). Only those keys are read; the file is parsed into a map and any other keys are +ignored, so a stray `.env` can never inject unrelated variables into the process environment. + +## Commands + +### Read workouts + +```bash +speediance-cli today --json # every session today, fully resolved (any type) +speediance-cli today --date 2026-06-17 --json # …or a specific day (today | yesterday | YYYY-MM-DD) +speediance-cli workouts --days 7 --json # recent sessions (summaries, for picking) +speediance-cli session --json # full, verbatim detail for one session +``` + +**`today` is the one-shot, agent-friendly entry point.** When the client just says +"I did a workout," call `today` β€” you do **not** need to know whether it was a +program, free weights, or a rowing/ski session. The tool finds the day's session(s) +and returns each one fully resolved, as an array of the same `{training_id, kind, +info, detail}` shape that `session` emits. (`kind` is `"program"`, `"free"`, or +`""`.) + +Sample `workouts --json` output (a digest for picking; `kind` lets you filter): + +```json +[ + { + "training_id": 123456, + "title": "Upper Body", + "date": "2025-06-15", + "duration_secs": 2700, + "calories": 320, + "volume": 4200.0, + "type": "Strength", + "kind": "program" + } +] +``` + +`session --json` is **autonomous and a faithful, complete passthrough**. Given +only an id it figures out what the session was β€” a program/Coach session, free +weights, or a rowing/ski free session β€” and emits the verbatim Speediance payloads +under a uniform shape. The field names, nesting, and values are exactly what +Speediance returned (`leftWatts`, `forceControlScore`, `weights`, `leftBreakTimes`, +`totalDistance`, …); the CLI does not rename, reshape, compute, or fill gaps. + +`kind` tells you which namespace answered, so a type-agnostic consumer always reads +the same two fields: + +| `kind` | `info` | `detail` | +|---|---|---| +| `"program"` | `cttTrainingInfo` payload (incl. `completionRate`) | `cttTrainingInfoDetail` β€” per-exercise, per-rep arrays | +| `"free"` | `freeTraining` payload (totals: `totalCapacity`, `totalEnergy`, `totalDistance` for rowing/ski; `name` for guided sessions) | `freeTrainingDetail` β€” `[]` for a freestyle Free Lift, **populated** for a guided session (e.g. *Aerobic Rowing* β†’ per-interval `finishedReps` with `distance`/`pace`/`spm` + per-stroke traces) | +| `""` | `null` | `null` (no session found in either namespace) | + +> `kind:"free"` is the *free namespace*, not "freestyle". It spans both a +> freestyle **Free Lift** (no `info.name`, `detail: []`, aggregates only) and a +> **guided** free-namespace session (has `info.name`, often a populated `detail`) β€” +> guided cardio like **Aerobic Rowing** carries the full per-interval breakdown. +> Distinguish via `info.name` + whether `detail` has rows; don't assume `free` β‡’ empty. + +A program session: + +```json +{ + "training_id": 940759, + "kind": "program", + "info": { + "completionRate": 100.0 + // … the verbatim GET /app/trainingInfo/cttTrainingInfo/ data payload + }, + "detail": [ + { + "actionLibraryName": "Standing Dual-Handle Hammer Curl", + "maxWeight": 15.0, "maxWeightCount": 5, + "score": 16, "completionScore": 5, "forceControlScore": 4, + "bilateralBalanceScore": 4, "amplitudeStableScore": 3, "actionRating": 3, + "finishedReps": [ + { + "finishedCount": 14, "targetCount": 14, "capacity": 330.0, "leftRight": 0, + "trainingInfoDetail": { + "weights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "leftWeights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "rightWeights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "leftWatts": [41.65,51.84, "…"], + "rightWatts": [26.28,55.05, "…"], + "leftAmplitudes": [0.46,0.68, "…"] + } + } + ] + } + ] +} +``` + +Notes for consumers: + +- **Auto-detection is built in** β€” no caller knowledge of the session type is + needed. `session ` probes the program namespace and falls back to free; + `today` picks each session's probe order from the day's record list (which + carries the authoritative type β€” free-first for non-program types), with the + same fallback. The result shape is identical either way. +- **`weight` is never invented.** There is no synthesized per-set weight. For a + program, the real per-rep weights are in `trainingInfoDetail.weights[]` (already + per attachment, so a single-handle average is just their mean); a mid-set drop + (e.g. `15Γ—5 β†’ 10Γ—9`) is therefore visible. Average or summarize as you see fit. +- **Free-namespace detail varies.** A *freestyle* Free Lift records session-level + totals only (`info` aggregates, `detail: []`). A *guided* free-namespace session + does more: **Aerobic Rowing** fills `detail` with per-interval rows + (`distance`/`pace`/`spm`/`time`) and per-stroke rope-length traces. Always read + `detail` rather than assuming `kind:"free"` is empty. +- **Absence is preserved.** A field or array Speediance omits is omitted in the + output too (e.g. a sparse capture with only `weights`); nothing is back-filled. +- **Values are unvalidated passthrough.** Speediance's fields aren't guaranteed + internally consistent, so derive the metric you want from raw values rather than + trusting a single field β€” e.g. a rowing split is `distance / time`, not the + per-interval `pace` field (which is an instantaneous sample). The CLI never + "corrects" a value; that interpretation is yours. +- **Empty shape.** `info` is `object | null`; `detail` is `array | null`. These are + the verbatim endpoint payloads (never normalized), so treat **both `null` and + `[]`** as "no rows" β€” e.g. `if not detail`. In practice `detail` is a populated + array for `kind:"program"`, `[]` for a freestyle Free Lift, **populated** for a + guided free-namespace session (see above), and `null` only for `kind:""`. +- **No flag unlocks data** β€” the endpoints return it, so the CLI returns it. There + is no `--telemetry`. + +> A `trainingId` can identify *different* sessions in the program vs. free +> namespaces. Auto-detection prefers the program match; pass `--program` or +> `--free` to `session` to force a namespace when an id is ambiguous. + +### Browse the exercise catalog + +```bash +speediance-cli library --search "chest" --json # filter by name or muscle +speediance-cli library # save full catalog to library.json +``` + +Returns `[{id, name, muscle, tab}]`. The `id` is required for plan JSON. +Every run saves the full catalog to `--out` (default `library.json`) β€” `--search` +filters only the stdout view, it does not narrow the saved file. +A committed `library.json` snapshot ships with the repo (Gym Monster v1) for offline +browsing β€” regenerate with `speediance-cli library` to get the freshest catalog or a +different device's exercises. + +### Create a training program + +Author a plan JSON, then push it β€” the program appears on the machine immediately: + +```bash +speediance-cli push plan.json --dry-run # preview payload, no network write +speediance-cli push plan.json # create it on the account +``` + +**Plan JSON format:** + +```json +{ + "name": "Pull Day", + "exercises": [ + { + "id": 434, + "title": "Seated Dual-Handle Lat Pulldown", + "sets": [ + {"reps": 12, "weight": 20, "mode": 1, "rest": 75}, + {"reps": 10, "weight": 22, "mode": 1, "rest": 75}, + {"reps": 8, "weight": 25, "mode": 1, "rest": 90} + ] + }, + { + "id": 291, + "title": "Seated Row", + "sets": [ + {"reps": 12, "weight": 18, "mode": 1, "rest": 60} + ] + } + ] +} +``` + +| Field | Type | Notes | +|---|---|---| +| `id` | int | From `speediance-cli library` β€” IDs differ per account/device | +| `weight` | float | **Kilograms** | +| `mode` | int | 1=Standard, 2=Eccentric, 3=Isokinetic, 4=Constant, 5=Spotter | +| `rest` | int | Seconds between sets | + +### Storing what you read + +The CLI owns no log format. To keep a record of a session, pull it with `workouts --json` +and `session --json`, then write it wherever you keep data (a Markdown sheet, a +database, a notebook). The tool reads and emits structured data; the caller decides the +layout. + +## Full command reference + +| Command | What it does | `--json` | +|---|---|---| +| `login` | Authenticate and cache a session token | β€” | +| `workouts [--days N]` | List recent completed sessions | βœ“ | +| `today [--date D]` | Every session on a day, auto-resolved to type-correct detail (the one-shot entry point) | βœ“ | +| `session [--free\|--program]` | Full, verbatim detail for one session; auto-detects program/free/rowing | βœ“ | +| `library [--search X] [--out FILE]` | Dump or search exercise catalog | βœ“ | +| `push [--dry-run]` | Create a training program on the account | βœ“ | +| `config show\|set\|path` | Manage `config.json` | βœ“ (`show`) | +| `version` | Build metadata (also `--version`) | βœ“ | +| `completion ` | Shell completion (bash/zsh/fish/powershell) | β€” | + +## Conventions + +- **stdout is parseable** with `--json`; all human-readable hints go to **stderr**. +- **Exit codes**: `0` success, `2` authentication failure, non-zero for other errors. +- **Secrets**: `config.json`, `.token.json`, `.env` are gitignored β€” never commit them. +- **Token caching**: after the first login, the token is cached in your **OS user-cache + directory** (e.g. `%LocalAppData%\speediance\token.json` on Windows, `~/.cache/speediance/token.json` + on Linux, `~/Library/Caches/speediance/token.json` on macOS) and refreshed automatically on + expiry β€” *not* in the working directory (so it can't be swept into a commit) and *not* in the + roaming config dir (so a live credential isn't synced across machines). Override the location with + `SPEEDIANCE_TOKEN_CACHE` or the `token_cache_path` config key; run `speediance-cli config path` to + see where it resolved. A token left in a legacy `.token.json` by an older version is moved to the + per-user location on first run. +- **Dry-run first**: always use `--dry-run` before `push` when authoring new programs to + confirm exercise IDs resolved correctly. +- If an endpoint breaks after a Speediance app update, all API calls live in + `internal/api` β€” that's the single place to patch. diff --git a/.cursor/rules/project-instructions.mdc b/.cursor/rules/project-instructions.mdc new file mode 100644 index 0000000..5bf10ae --- /dev/null +++ b/.cursor/rules/project-instructions.mdc @@ -0,0 +1,11 @@ +--- +description: Shared project instructions +alwaysApply: true +--- + +Read and follow the shared instructions before working. + +@docs/OPERATING_INSTRUCTIONS.md +@docs/PROJECT_INSTRUCTIONS.md + +@docs/MACHINE_CONTRACT.md diff --git a/.cursor/skills/speediance/SKILL.md b/.cursor/skills/speediance/SKILL.md new file mode 100644 index 0000000..225048c --- /dev/null +++ b/.cursor/skills/speediance/SKILL.md @@ -0,0 +1,331 @@ +--- +name: speediance +description: > + Read completed workouts (summaries and full per-set detail), browse and export the + exercise catalog, and push custom training programs to your Speediance (Gym Monster) + smart cable machine via its cloud API. Authenticates with your account credentials, + caches a session token in your OS user-cache directory (override with SPEEDIANCE_TOKEN_CACHE), + and makes outbound HTTPS requests to the Speediance cloud API. Reads and emits structured + data β€” the caller decides where to store it. Ships as a single static binary β€” no Python or + other runtime required. +metadata: + openclaw: + emoji: πŸ‹οΈ + homepage: https://github.com/stozo04/speediance-cli + primaryEnv: SPEEDIANCE_EMAIL + permissions: + network: + - "Speediance cloud API (HTTPS) β€” authentication, workout history, exercise catalog, program creation" + files.read: + - "config.json β€” optional credential/config file (working directory, or your OS user-config dir)" + - ".env β€” optional env file in the working directory; only the documented SPEEDIANCE_* keys are read (parsed to a map, never exported into the process environment)" + - "token cache β€” cached session token in your OS user-cache dir by default (non-roaming); path overridable via SPEEDIANCE_TOKEN_CACHE or the token_cache_path config key. A legacy .token.json in the working directory is read once to migrate it." + - "plan JSON files passed to the push command" + files.write: + - "token cache β€” session token written after login and refreshed automatically; in your OS user-cache dir by default (non-roaming; override via SPEEDIANCE_TOKEN_CACHE or token_cache_path). A legacy .token.json in the working directory is relocated here and then removed." + - "library.json β€” exercise catalog dump (library command; every run writes the full catalog to --out, default library.json)" + - "config.json β€” written by the config set command (owner-only permissions)" + requires: + bins: [] + env: + - SPEEDIANCE_EMAIL + - SPEEDIANCE_PASSWORD + envVars: + - name: SPEEDIANCE_EMAIL + description: Your Speediance / Gym Monster account email address + required: true + - name: SPEEDIANCE_PASSWORD + description: "Your account password. Google/SSO users: set a password once in the Speediance app (Profile β†’ Settings) before using this skill." + required: true + - name: SPEEDIANCE_REGION + description: "API region β€” Global (default) or EU" + required: false + - name: SPEEDIANCE_DEVICE_TYPE + description: "Device type integer β€” 1 = Gym Monster v1 (default, tested). Gym Monster 2 is untested; try 2 if exercises look wrong." + required: false + - name: SPEEDIANCE_CONFIG + description: "Path to config.json (overrides discovery: working dir, then OS user-config dir)" + required: false + - name: SPEEDIANCE_TOKEN_CACHE + description: "Override the token cache file location (default: OS user-cache dir)" + required: false +--- + +# Speediance β€” Gym Monster CLI Skill + +Talk to your **Speediance (Gym Monster)** smart cable machine from any agent. Read +completed workouts and push custom programs that appear on the machine ready to run β€” +no app navigation mid-session. + +> **Unofficial** β€” reverse-engineered from the Android app. Personal use, your own +> account only. Built on the MIT-licensed `UnofficialSpeedianceWorkoutManager` (hbui3) +> and `speediance-influx` (gavinmcfall). +> +> Tested on **Gym Monster v1** (`SPEEDIANCE_DEVICE_TYPE=1`). GM2 is untested. + +## Setup (one time) + +`speediance-cli` is a single static binary β€” **no Python or other runtime needed**. + +Install it one of two ways: + +```bash +# A) Download a release binary for your OS/arch, extract, put it on your PATH: +# https://github.com/stozo04/speediance-cli/releases + +# B) Or build/install with Go (1.24+): +go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latest +``` + +Then authenticate: + +```bash +speediance-cli login # authenticates and caches a session token (run `config path` to see where) +``` + +## Credentials + +Set as environment variables β€” the CLI reads them automatically: + +| Variable | Required | Default | Notes | +|---|---|---|---| +| `SPEEDIANCE_EMAIL` | βœ“ | β€” | Account email | +| `SPEEDIANCE_PASSWORD` | βœ“ | β€” | Account password | +| `SPEEDIANCE_REGION` | β€” | `Global` | `Global` or `EU` | +| `SPEEDIANCE_DEVICE_TYPE` | β€” | `1` | `1` = Gym Monster v1 | + +Alternatively, write a `config.json` in the working directory (gitignored by the repo): + +```json +{ + "email": "you@example.com", + "password": "yourpassword", + "region": "Global" +} +``` + +You can also put these variables in a gitignored **`.env`** file in the working directory β€” the +`SPEEDIANCE_*` keys are read from it automatically (exported environment variables still take +precedence). Only those keys are read; the file is parsed into a map and any other keys are +ignored, so a stray `.env` can never inject unrelated variables into the process environment. + +## Commands + +### Read workouts + +```bash +speediance-cli today --json # every session today, fully resolved (any type) +speediance-cli today --date 2026-06-17 --json # …or a specific day (today | yesterday | YYYY-MM-DD) +speediance-cli workouts --days 7 --json # recent sessions (summaries, for picking) +speediance-cli session --json # full, verbatim detail for one session +``` + +**`today` is the one-shot, agent-friendly entry point.** When the client just says +"I did a workout," call `today` β€” you do **not** need to know whether it was a +program, free weights, or a rowing/ski session. The tool finds the day's session(s) +and returns each one fully resolved, as an array of the same `{training_id, kind, +info, detail}` shape that `session` emits. (`kind` is `"program"`, `"free"`, or +`""`.) + +Sample `workouts --json` output (a digest for picking; `kind` lets you filter): + +```json +[ + { + "training_id": 123456, + "title": "Upper Body", + "date": "2025-06-15", + "duration_secs": 2700, + "calories": 320, + "volume": 4200.0, + "type": "Strength", + "kind": "program" + } +] +``` + +`session --json` is **autonomous and a faithful, complete passthrough**. Given +only an id it figures out what the session was β€” a program/Coach session, free +weights, or a rowing/ski free session β€” and emits the verbatim Speediance payloads +under a uniform shape. The field names, nesting, and values are exactly what +Speediance returned (`leftWatts`, `forceControlScore`, `weights`, `leftBreakTimes`, +`totalDistance`, …); the CLI does not rename, reshape, compute, or fill gaps. + +`kind` tells you which namespace answered, so a type-agnostic consumer always reads +the same two fields: + +| `kind` | `info` | `detail` | +|---|---|---| +| `"program"` | `cttTrainingInfo` payload (incl. `completionRate`) | `cttTrainingInfoDetail` β€” per-exercise, per-rep arrays | +| `"free"` | `freeTraining` payload (totals: `totalCapacity`, `totalEnergy`, `totalDistance` for rowing/ski; `name` for guided sessions) | `freeTrainingDetail` β€” `[]` for a freestyle Free Lift, **populated** for a guided session (e.g. *Aerobic Rowing* β†’ per-interval `finishedReps` with `distance`/`pace`/`spm` + per-stroke traces) | +| `""` | `null` | `null` (no session found in either namespace) | + +> `kind:"free"` is the *free namespace*, not "freestyle". It spans both a +> freestyle **Free Lift** (no `info.name`, `detail: []`, aggregates only) and a +> **guided** free-namespace session (has `info.name`, often a populated `detail`) β€” +> guided cardio like **Aerobic Rowing** carries the full per-interval breakdown. +> Distinguish via `info.name` + whether `detail` has rows; don't assume `free` β‡’ empty. + +A program session: + +```json +{ + "training_id": 940759, + "kind": "program", + "info": { + "completionRate": 100.0 + // … the verbatim GET /app/trainingInfo/cttTrainingInfo/ data payload + }, + "detail": [ + { + "actionLibraryName": "Standing Dual-Handle Hammer Curl", + "maxWeight": 15.0, "maxWeightCount": 5, + "score": 16, "completionScore": 5, "forceControlScore": 4, + "bilateralBalanceScore": 4, "amplitudeStableScore": 3, "actionRating": 3, + "finishedReps": [ + { + "finishedCount": 14, "targetCount": 14, "capacity": 330.0, "leftRight": 0, + "trainingInfoDetail": { + "weights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "leftWeights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "rightWeights": [15,15,15,15,15,10,10,10,10,10,10,10,10,10], + "leftWatts": [41.65,51.84, "…"], + "rightWatts": [26.28,55.05, "…"], + "leftAmplitudes": [0.46,0.68, "…"] + } + } + ] + } + ] +} +``` + +Notes for consumers: + +- **Auto-detection is built in** β€” no caller knowledge of the session type is + needed. `session ` probes the program namespace and falls back to free; + `today` picks each session's probe order from the day's record list (which + carries the authoritative type β€” free-first for non-program types), with the + same fallback. The result shape is identical either way. +- **`weight` is never invented.** There is no synthesized per-set weight. For a + program, the real per-rep weights are in `trainingInfoDetail.weights[]` (already + per attachment, so a single-handle average is just their mean); a mid-set drop + (e.g. `15Γ—5 β†’ 10Γ—9`) is therefore visible. Average or summarize as you see fit. +- **Free-namespace detail varies.** A *freestyle* Free Lift records session-level + totals only (`info` aggregates, `detail: []`). A *guided* free-namespace session + does more: **Aerobic Rowing** fills `detail` with per-interval rows + (`distance`/`pace`/`spm`/`time`) and per-stroke rope-length traces. Always read + `detail` rather than assuming `kind:"free"` is empty. +- **Absence is preserved.** A field or array Speediance omits is omitted in the + output too (e.g. a sparse capture with only `weights`); nothing is back-filled. +- **Values are unvalidated passthrough.** Speediance's fields aren't guaranteed + internally consistent, so derive the metric you want from raw values rather than + trusting a single field β€” e.g. a rowing split is `distance / time`, not the + per-interval `pace` field (which is an instantaneous sample). The CLI never + "corrects" a value; that interpretation is yours. +- **Empty shape.** `info` is `object | null`; `detail` is `array | null`. These are + the verbatim endpoint payloads (never normalized), so treat **both `null` and + `[]`** as "no rows" β€” e.g. `if not detail`. In practice `detail` is a populated + array for `kind:"program"`, `[]` for a freestyle Free Lift, **populated** for a + guided free-namespace session (see above), and `null` only for `kind:""`. +- **No flag unlocks data** β€” the endpoints return it, so the CLI returns it. There + is no `--telemetry`. + +> A `trainingId` can identify *different* sessions in the program vs. free +> namespaces. Auto-detection prefers the program match; pass `--program` or +> `--free` to `session` to force a namespace when an id is ambiguous. + +### Browse the exercise catalog + +```bash +speediance-cli library --search "chest" --json # filter by name or muscle +speediance-cli library # save full catalog to library.json +``` + +Returns `[{id, name, muscle, tab}]`. The `id` is required for plan JSON. +Every run saves the full catalog to `--out` (default `library.json`) β€” `--search` +filters only the stdout view, it does not narrow the saved file. +A committed `library.json` snapshot ships with the repo (Gym Monster v1) for offline +browsing β€” regenerate with `speediance-cli library` to get the freshest catalog or a +different device's exercises. + +### Create a training program + +Author a plan JSON, then push it β€” the program appears on the machine immediately: + +```bash +speediance-cli push plan.json --dry-run # preview payload, no network write +speediance-cli push plan.json # create it on the account +``` + +**Plan JSON format:** + +```json +{ + "name": "Pull Day", + "exercises": [ + { + "id": 434, + "title": "Seated Dual-Handle Lat Pulldown", + "sets": [ + {"reps": 12, "weight": 20, "mode": 1, "rest": 75}, + {"reps": 10, "weight": 22, "mode": 1, "rest": 75}, + {"reps": 8, "weight": 25, "mode": 1, "rest": 90} + ] + }, + { + "id": 291, + "title": "Seated Row", + "sets": [ + {"reps": 12, "weight": 18, "mode": 1, "rest": 60} + ] + } + ] +} +``` + +| Field | Type | Notes | +|---|---|---| +| `id` | int | From `speediance-cli library` β€” IDs differ per account/device | +| `weight` | float | **Kilograms** | +| `mode` | int | 1=Standard, 2=Eccentric, 3=Isokinetic, 4=Constant, 5=Spotter | +| `rest` | int | Seconds between sets | + +### Storing what you read + +The CLI owns no log format. To keep a record of a session, pull it with `workouts --json` +and `session --json`, then write it wherever you keep data (a Markdown sheet, a +database, a notebook). The tool reads and emits structured data; the caller decides the +layout. + +## Full command reference + +| Command | What it does | `--json` | +|---|---|---| +| `login` | Authenticate and cache a session token | β€” | +| `workouts [--days N]` | List recent completed sessions | βœ“ | +| `today [--date D]` | Every session on a day, auto-resolved to type-correct detail (the one-shot entry point) | βœ“ | +| `session [--free\|--program]` | Full, verbatim detail for one session; auto-detects program/free/rowing | βœ“ | +| `library [--search X] [--out FILE]` | Dump or search exercise catalog | βœ“ | +| `push [--dry-run]` | Create a training program on the account | βœ“ | +| `config show\|set\|path` | Manage `config.json` | βœ“ (`show`) | +| `version` | Build metadata (also `--version`) | βœ“ | +| `completion ` | Shell completion (bash/zsh/fish/powershell) | β€” | + +## Conventions + +- **stdout is parseable** with `--json`; all human-readable hints go to **stderr**. +- **Exit codes**: `0` success, `2` authentication failure, non-zero for other errors. +- **Secrets**: `config.json`, `.token.json`, `.env` are gitignored β€” never commit them. +- **Token caching**: after the first login, the token is cached in your **OS user-cache + directory** (e.g. `%LocalAppData%\speediance\token.json` on Windows, `~/.cache/speediance/token.json` + on Linux, `~/Library/Caches/speediance/token.json` on macOS) and refreshed automatically on + expiry β€” *not* in the working directory (so it can't be swept into a commit) and *not* in the + roaming config dir (so a live credential isn't synced across machines). Override the location with + `SPEEDIANCE_TOKEN_CACHE` or the `token_cache_path` config key; run `speediance-cli config path` to + see where it resolved. A token left in a legacy `.token.json` by an older version is moved to the + per-user location on first run. +- **Dry-run first**: always use `--dry-run` before `push` when authoring new programs to + confirm exercise IDs resolved correctly. +- If an endpoint breaks after a Speediance app update, all API calls live in + `internal/api` β€” that's the single place to patch. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1201d0e..61249c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: - uses: actions/setup-go@v6 with: go-version: stable + - name: Check agent instructions + run: make check-agents - name: Build run: go build ./... - name: Test (race) @@ -29,13 +31,14 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: - go-version: stable + go-version: "1.26.x" - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: # golangci-lint embeds the Go type-checker of the toolchain it was # built with, so it must be built with a Go >= the toolchain it lints - # under (here: stable/go1.26). v2.12.2 is built with go1.26; bump this - # in lockstep if go.mod's `go` directive ever outpaces the linter, + # under. This lane pins Go 1.26 to match v2.12.2; build/test still use + # stable. Bump this pair together when adopting a newer lint toolchain; + # also update them if go.mod's `go` directive outpaces the linter, # otherwise it panics: "file requires newer Go version ... (built with ...)". version: v2.12.2 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 4c59e8e..84ccb8a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -47,6 +47,14 @@ archives: files: - README.md - AGENTS.md + - SKILL.md + - docs/*.md + - .claude/CLAWHUB_STANDARDS.md + - .claude/CLI_CONVENTIONS.md + - CONTRIBUTING.md + - SECURITY.md + - GOAL.md + - RELEASING.md - LICENSE checksum: diff --git a/AGENTS.md b/AGENTS.md index 4107731..e7a4851 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,179 +1,5 @@ -# AGENTS.md β€” using speediance-cli from an agent +Read [operating instructions](docs/OPERATING_INSTRUCTIONS.md), then [project instructions](docs/PROJECT_INSTRUCTIONS.md). -This repo is a single-binary **Go** CLI for the Speediance (Gym Monster) cloud API. It's -built to be driven by an agent (OpenClaw, Claude, etc.): every command has a `--json` -mode, and the CLI **does not own any user data layout** β€” it returns structured data and -creates programs; the *caller* decides what to do with it (write to a sheet, a database, a -notebook, wherever). No Python or other runtime is required. +These shared documents are authoritative for every agent. Edit them instead of forking per-tool guidance. -> **Device note:** built and tested for the **Gym Monster (v1)** (`device_type = 1`). -> A **Gym Monster 2** exists and may use a different device type and exercise ids β€” -> UNTESTED. Override via `SPEEDIANCE_DEVICE_TYPE` or `device_type` in config.json. - -## 1. Setup (do this once) - -**Option A β€” download a release binary** (no toolchain needed): grab the archive for your -OS/arch from the [Releases](https://github.com/stozo04/speediance-cli/releases) page, -extract, and put `speediance-cli` on your `PATH`. - -**Option B β€” install with Go** (1.24+): - -```bash -go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latest -``` - -This installs `speediance-cli` into `$(go env GOPATH)/bin`; ensure that's on your `PATH`. - -## 2. Credentials (find them, don't hardcode them) - -The CLI needs the user's Speediance **email + password**. Resolution order -(highest precedence first): **command flags β†’ environment variables β†’ `config.json` β†’ -built-in defaults**. - -1. **Environment variables** (preferred for agents): `SPEEDIANCE_EMAIL`, - `SPEEDIANCE_PASSWORD`, optional `SPEEDIANCE_REGION` (`Global` default, or `EU`) and - `SPEEDIANCE_DEVICE_TYPE` (`1` = Gym Monster v1, the only tested device). A gitignored - **`.env`** file in the working directory is read automatically for these `SPEEDIANCE_*` - keys (real exported variables take precedence over it), so they can live in `.env` instead - of being exported. Only the `SPEEDIANCE_*` keys are read from `.env`; the file is parsed - into a map and any other keys are ignored β€” nothing is injected into the process environment. -2. **`config.json`** in the working directory β€” copy `config.example.json` to - `config.json` and fill it in. This file is gitignored; never commit it. (You can also - point `--config PATH` or `SPEEDIANCE_CONFIG` at an explicit file.) -3. If neither is set, **ask the user** (or read from their secret store / password - manager). Do not invent or guess credentials. - -Google/SSO accounts: the user must set a password in the Speediance app once -(`verifyIdentity` reports `hasPwd:false` otherwise). Email stays their Google email. - -Verify it works: - -```bash -speediance-cli login # caches a session token (0600) in your OS user-cache dir -``` - -You can inspect the resolved configuration any time with `speediance-cli config show` -(the password is masked) or `speediance-cli config path` (file locations). - -## 3. Read workouts - -```bash -# every session today, fully resolved β€” the one-shot entry point (no type knowledge needed) -speediance-cli today --json -speediance-cli today --date 2026-06-17 --json # today | yesterday | YYYY-MM-DD - -# recent completed sessions (a digest, for picking); each row carries `kind` -speediance-cli workouts --days 7 --json - -# full, verbatim detail for one session; auto-detects program/free/rowing -speediance-cli session --json -``` - -**The tool auto-detects the session type β€” the agent stays dumb.** When the client -says they did a workout, call `today`: it finds the day's session(s) and returns -each fully resolved, without you knowing whether it was a program, free weights, or -rowing. `session ` does the same for one id (probes the program namespace, then -free). - -Output is a uniform **`{training_id, kind, info, detail}`** (`today` returns an -array of these). `kind` is `"program"`, `"free"`, or `""`: - -- `kind:"program"` β†’ `info` = `cttTrainingInfo` (incl. `completionRate`); `detail` = - per-exercise, per-rep arrays. -- `kind:"free"` β†’ the free *namespace* (not "freestyle"): `info` = `freeTraining` - totals (`totalCapacity`, `totalEnergy`, `totalDistance` for rowing/ski; `name` for - guided). `detail` is `[]` for a freestyle Free Lift, but **populated** for a - guided session β€” e.g. *Aerobic Rowing* fills it with per-interval - `finishedReps` (`distance`/`pace`/`spm`) + per-stroke traces. Read `detail`; - don't assume `free` β‡’ empty. (`info.name` present β‡’ a guided session.) - -`info`/`detail` are the **verbatim** Speediance payloads β€” original field names and -values (`leftWatts`, `forceControlScore`, `weights`, `leftBreakTimes`, …). The CLI -never renames, reshapes, computes, or fabricates; there is no synthesized per-set -weight and no `--telemetry` flag. Absence is preserved (omitted fields stay -omitted). `info` is `object | null` and `detail` is `array | null` (never -normalized) β€” treat both `null` and `[]` as "no rows". Values are **unvalidated**: -Speediance's fields aren't always self-consistent, so derive what you need from raw -values (e.g. a rowing split = `distance / time`, not the per-interval `pace` field, -which is instantaneous) rather than trusting one field. A `trainingId` can mean -different sessions across namespaces; auto-detect prefers program, and -`--free`/`--program` force a namespace when an id is ambiguous. - -## 4. Create a workout (so it appears on the machine) - -```bash -# 1) cache the user's exercise catalog (ids differ per device/account) -speediance-cli library # writes library.json: {id, name, muscle, tab} -speediance-cli library --search "row" --json -``` - -A committed `library.json` snapshot ships with the repo (Gym Monster 1); regenerate it -with the command above for the freshest catalog or a different device. - -```bash -# 2) write a plan JSON (you, the agent, author this), then: -speediance-cli push plan.json --dry-run # preview payload, no network write -speediance-cli push plan.json # create it on the account -``` - -### Plan JSON - -```json -{ - "name": "Pull Day", - "exercises": [ - {"id": 434, "title": "Seated Dual-Handle Lat Pulldown", - "sets": [{"reps": 12, "weight": 20, "mode": 1, "rest": 75}]} - ] -} -``` - -- `id` β€” from `library.json` -- `weight` β€” **kilograms** (stored internally as `kg Γ— 2.2`; confirm the displayed - unit on the machine on first use and adjust if needed) -- `mode` β€” 1 Standard, 2 Eccentric, 3 Isokinetic, 4 Constant, 5 Spotter -- `rest` β€” seconds - -Always `--dry-run` first when authoring new programs to confirm exercise ids resolved. - -## 5. Storing what you read (it's the caller's job) - -The CLI does not own a log format. To keep a record of a session, **pull** it with -`workouts --days N --json` and `session --json`, then write it wherever you -keep data (a Markdown sheet, a database, a notebook). The tool reads and emits; the caller -decides the layout. Note that freestyle **"Free Lift"** sessions return totals only β€” no -per-set detail to store. - -## Conventions - -- **stdout is parseable** with `--json`; human hints, warnings, and logs go to **stderr**. - They are never interleaved, so piping stdout into a parser is safe. -- **Exit codes:** `0` success, `2` auth failure, non-zero for other errors. Check them. -- **No `doctor`/health command β€” by design.** To diagnose setup programmatically, read - `config show --json` (what resolved, where) and run `login` (exit `2` = auth/connectivity - failure; it rewrites the token cache). Don't go looking for a single health command β€” chain - those instead. -- **Token cache:** the session token is cached in the OS user-**cache** dir by default - (non-roaming; `config path` shows the resolved location), **not** the working directory - (so running the CLI from another repo can't drop a credential into it) and **not** the - roaming config dir (so a live token isn't synced across machines). Override with - `SPEEDIANCE_TOKEN_CACHE` or the `token_cache_path` config key. -- **Secrets:** `config.json`, `.token.json`, `.env` are gitignored. Never commit them. -- **Device:** tested for Gym Monster 1 only; GM2 untested. -- **Unofficial API:** all endpoints live in `internal/api`; if the Speediance app updates - and something breaks, that's the single place to patch. -- Branch `main` is PR-protected β€” changes land via pull request. - -## Command surface - -| Command | Purpose | `--json` | -|---|---|---| -| `login` | authenticate, cache token | β€” | -| `workouts --days N` | list recent sessions | yes | -| `today [--date D]` | every session on a day, auto-resolved to type-correct detail | yes | -| `session [--free\|--program]` | full, verbatim detail for one session; auto-detects type | yes | -| `library` | dump exercise catalog to `library.json` | yes | -| `push ` | create a program (`--dry-run` to preview) | yes | -| `config show\|set\|path` | manage `config.json` | yes (`show`) | -| `version` | build metadata (also `--version`) | yes | -| `completion ` | shell completion script | β€” | +CLI consumers: read [the machine contract](docs/MACHINE_CONTRACT.md). diff --git a/CLAUDE.md b/CLAUDE.md index c190a16..e7a4851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,88 +1,5 @@ -# CLAUDE.md β€” contributor guidance for Claude Code +Read [operating instructions](docs/OPERATING_INSTRUCTIONS.md), then [project instructions](docs/PROJECT_INSTRUCTIONS.md). -Developer-facing brief for working **on** `speediance-cli` (a single-binary Go -CLI for the unofficial Speediance / Gym Monster cloud API). For *using* the CLI -from an agent see `AGENTS.md`; for the design contract see `GOAL.md`. +These shared documents are authoritative for every agent. Edit them instead of forking per-tool guidance. -## MANDATORY β€” ClawHub security standards - -Before changing anything that touches **credential handling, configuration -resolution, environment/`.env` loading, file writes, network calls, logging -output, or `SKILL.md`**, and before **publishing the skill to ClawHub**, you MUST -read and follow `.claude/CLAWHUB_STANDARDS.md`. - -It is imported just below so its full text is always in your context β€” treat its -rules and pre-publish checklist as binding, and pin every new security behavior -with an immutable regression test (as described there). - -@.claude/CLAWHUB_STANDARDS.md - -## MANDATORY β€” shared CLI conventions - -`speediance-cli` shares its config/auth/credential layer design with its sibling -`google-health-cli`. The cross-repo invariants for that layer (per-user/non-roaming -secret locations, `0600`/`0700` perms, advertised==actual, conservative migration, -`.env` no-inject, …) live in `.claude/CLI_CONVENTIONS.md`, committed **byte-identical** -in both repos. Changes go through the shared agent process so both copies stay in sync β€” -do not edit one repo's copy unilaterally. It is imported just below so its full text is -always in your context. - -@.claude/CLI_CONVENTIONS.md - -## Build & verify - -- `go build ./... && go vet ./... && go test ./...` must pass; `gofmt -l` must be clean. -- `main` is PR-protected β€” land changes via pull request. - -## Local reference data β€” `REAL-SPEEDIANCE-JSON/` - -If present on the working machine, `REAL-SPEEDIANCE-JSON/` holds **real captured -Speediance session JSON** from a live account β€” the ground-truth shapes of actual -CLI output / API responses per session `kind`/`type` (program, freestyle Free Lift, -guided rowing, the `today` array, …). See its `README.md` for an index. - -- It is **gitignored on purpose** β€” personal workout data in a public repo. **Never - commit it, never move it back into a tracked path, and never paste its contents - into a PR, issue, commit, or published doc.** It is local reference only. -- **Prefer it over fabricating samples.** When changing session/dispatch code or - reasoning about the `--json` contract, read a real file here instead of inventing a - shape. Save any new live captures into this folder. -- It may be **absent** (a fresh clone / CI / another machine) β€” that's expected; the - test fixtures (e.g. the genuine `940759` data embedded in tests) are the committed, - CI-visible source of truth. This folder is a convenience, never a dependency. - -## PR gate β€” guard tests are mandatory and immutable - -A PR may not be opened or merged unless `go build ./... && go vet ./... && go test -race ./...` -pass and `gofmt -l` is clean. This is **enforced, not advisory**: CI -(`.github/workflows/ci.yml`) runs build + `go test -race` + lint on every `pull_request`. - -The **negative-assertion guard tests** β€” every test named in the SPD cells of -`.claude/CLI_CONVENTIONS.md` (Β§0, Β§1, Β§3, Β§5, Β§7, Β§9) **plus** -`internal/cli`'s `TestEndToEndMigratesLegacyTokenToCacheDir` β€” are **immutable**: each -asserts that a known bad thing does **not** happen (a secret in CWD, a token in the -roaming base, a `.env` mutating the process env, an advertised-but-unwired key, …). They -must never be skipped (`t.Skip`), deleted, or weakened to turn a PR green β€” a red guard -means **fix the code, not the test**. Any new credential / config / permission / network -behavior ships with its guard in the **same** PR (see `.claude/CLAWHUB_STANDARDS.md`). - -## Commits & releases - -Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org) -(`feat:`, `fix:`, `docs:`, `test:`, `chore:`) β€” this is **load-bearing, not cosmetic**. -Releases are cut by pushing a `vX.Y.Z` git tag (never by merging), and GoReleaser -auto-builds the GitHub Release notes by **grouping commit subjects** (`feat:` β†’ Features, -`fix:` β†’ Bug fixes, else β†’ Other changes; `docs:`/`test:`/`chore:` excluded) plus a static -install footer β€” there is **no `CHANGELOG.md`**. Use the right prefix so the changelog groups -cleanly, and squash-merge PRs with a clean Conventional-Commit title. Full release playbook -(versioning, tagging, dry-runs): `Releasing.md`. - -## Scope β€” don't add a `doctor`/health command - -The diagnostic surface is **intentionally** spread across existing commands, not bundled -into a `doctor`: `version` (install/build), `config show` + `config path` (resolved config -and file locations), and `login` (auth + connectivity; exit `2` on failure). With a single -external dependency (the Speediance API) and an agent-first consumer that prefers `--json` -+ exit codes over a human-readable health report, a `doctor` aggregator is speculative -surface that cuts against the minimal-command philosophy (GOAL.md). Revisit only if -human-user support load makes a read-only aggregator clearly worth its weight. +CLI consumers: read [the machine contract](docs/MACHINE_CONTRACT.md). diff --git a/Makefile b/Makefile index bc1e1dc..39429e7 100644 --- a/Makefile +++ b/Makefile @@ -56,8 +56,13 @@ snapshot: ## Cross-compile a local snapshot with GoReleaser (no publish) goreleaser build --snapshot --clean .PHONY: check -check: tidy fmt vet lint test-race ## Run the full pre-commit gauntlet +check: check-agents tidy fmt vet lint test-race ## Run the full pre-commit gauntlet .PHONY: clean clean: ## Remove build artifacts rm -rf bin dist coverage.out $(BINARY) $(BINARY).exe + +.PHONY: check-agents +check-agents: ## Check shared instructions and skill mirrors (Python 3) + python scripts/sync-harness-skills.py --check + python scripts/test-sync-harness-skills.py diff --git a/RELEASING.md b/RELEASING.md index cd0ae2e..d69b2fb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -177,8 +177,9 @@ end up with two different "v1.0.1"s, which is the thing versioning exists to pre - **Tags must start with `v`** β€” the release workflow only triggers on `v*`. - **golangci-lint vs. toolchain:** golangci-lint embeds the Go type-checker of the Go version it was built with, so the linter must be built with a Go **β‰₯** the toolchain - it lints under. `ci.yml`'s lint job runs on `stable` with golangci-lint `v2.12.2` - (built with go1.26). If you bump the `go` directive in `go.mod` past the linter's + it lints under. `ci.yml`'s lint job pins Go `1.26.x` with golangci-lint `v2.12.2` + (built with go1.26); build/test still run on `stable`. Upgrade the lint + toolchain and linter together. If you bump the `go` directive in `go.mod` past the linter's build version, bump `version:` in the lint job too, or it panics with `file requires newer Go version ... (built with ...)`. - **GoReleaser runs `go mod tidy`** before building β€” keep `go.mod`/`go.sum` tidy or it diff --git a/docs/MACHINE_CONTRACT.md b/docs/MACHINE_CONTRACT.md new file mode 100644 index 0000000..7379079 --- /dev/null +++ b/docs/MACHINE_CONTRACT.md @@ -0,0 +1,179 @@ +# Speediance CLI machine contract + +This repo is a single-binary **Go** CLI for the Speediance (Gym Monster) cloud API. It's +built to be driven by an agent (OpenClaw, Claude, etc.): every command has a `--json` +mode, and the CLI **does not own any user data layout** β€” it returns structured data and +creates programs; the *caller* decides what to do with it (write to a sheet, a database, a +notebook, wherever). No Python or other runtime is required. + +> **Device note:** built and tested for the **Gym Monster (v1)** (`device_type = 1`). +> A **Gym Monster 2** exists and may use a different device type and exercise ids β€” +> UNTESTED. Override via `SPEEDIANCE_DEVICE_TYPE` or `device_type` in config.json. + +## 1. Setup (do this once) + +**Option A β€” download a release binary** (no toolchain needed): grab the archive for your +OS/arch from the [Releases](https://github.com/stozo04/speediance-cli/releases) page, +extract, and put `speediance-cli` on your `PATH`. + +**Option B β€” install with Go** (1.24+): + +```bash +go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latest +``` + +This installs `speediance-cli` into `$(go env GOPATH)/bin`; ensure that's on your `PATH`. + +## 2. Credentials (find them, don't hardcode them) + +The CLI needs the user's Speediance **email + password**. Resolution order +(highest precedence first): **command flags β†’ environment variables β†’ `config.json` β†’ +built-in defaults**. + +1. **Environment variables** (preferred for agents): `SPEEDIANCE_EMAIL`, + `SPEEDIANCE_PASSWORD`, optional `SPEEDIANCE_REGION` (`Global` default, or `EU`) and + `SPEEDIANCE_DEVICE_TYPE` (`1` = Gym Monster v1, the only tested device). A gitignored + **`.env`** file in the working directory is read automatically for these `SPEEDIANCE_*` + keys (real exported variables take precedence over it), so they can live in `.env` instead + of being exported. Only the `SPEEDIANCE_*` keys are read from `.env`; the file is parsed + into a map and any other keys are ignored β€” nothing is injected into the process environment. +2. **`config.json`** in the working directory β€” copy `config.example.json` to + `config.json` and fill it in. This file is gitignored; never commit it. (You can also + point `--config PATH` or `SPEEDIANCE_CONFIG` at an explicit file.) +3. If neither is set, **ask the user** (or read from their secret store / password + manager). Do not invent or guess credentials. + +Google/SSO accounts: the user must set a password in the Speediance app once +(`verifyIdentity` reports `hasPwd:false` otherwise). Email stays their Google email. + +Verify it works: + +```bash +speediance-cli login # caches a session token (0600) in your OS user-cache dir +``` + +You can inspect the resolved configuration any time with `speediance-cli config show` +(the password is masked) or `speediance-cli config path` (file locations). + +## 3. Read workouts + +```bash +# every session today, fully resolved β€” the one-shot entry point (no type knowledge needed) +speediance-cli today --json +speediance-cli today --date 2026-06-17 --json # today | yesterday | YYYY-MM-DD + +# recent completed sessions (a digest, for picking); each row carries `kind` +speediance-cli workouts --days 7 --json + +# full, verbatim detail for one session; auto-detects program/free/rowing +speediance-cli session --json +``` + +**The tool auto-detects the session type β€” the agent stays dumb.** When the client +says they did a workout, call `today`: it finds the day's session(s) and returns +each fully resolved, without you knowing whether it was a program, free weights, or +rowing. `session ` does the same for one id (probes the program namespace, then +free). + +Output is a uniform **`{training_id, kind, info, detail}`** (`today` returns an +array of these). `kind` is `"program"`, `"free"`, or `""`: + +- `kind:"program"` β†’ `info` = `cttTrainingInfo` (incl. `completionRate`); `detail` = + per-exercise, per-rep arrays. +- `kind:"free"` β†’ the free *namespace* (not "freestyle"): `info` = `freeTraining` + totals (`totalCapacity`, `totalEnergy`, `totalDistance` for rowing/ski; `name` for + guided). `detail` is `[]` for a freestyle Free Lift, but **populated** for a + guided session β€” e.g. *Aerobic Rowing* fills it with per-interval + `finishedReps` (`distance`/`pace`/`spm`) + per-stroke traces. Read `detail`; + don't assume `free` β‡’ empty. (`info.name` present β‡’ a guided session.) + +`info`/`detail` are the **verbatim** Speediance payloads β€” original field names and +values (`leftWatts`, `forceControlScore`, `weights`, `leftBreakTimes`, …). The CLI +never renames, reshapes, computes, or fabricates; there is no synthesized per-set +weight and no `--telemetry` flag. Absence is preserved (omitted fields stay +omitted). `info` is `object | null` and `detail` is `array | null` (never +normalized) β€” treat both `null` and `[]` as "no rows". Values are **unvalidated**: +Speediance's fields aren't always self-consistent, so derive what you need from raw +values (e.g. a rowing split = `distance / time`, not the per-interval `pace` field, +which is instantaneous) rather than trusting one field. A `trainingId` can mean +different sessions across namespaces; auto-detect prefers program, and +`--free`/`--program` force a namespace when an id is ambiguous. + +## 4. Create a workout (so it appears on the machine) + +```bash +# 1) cache the user's exercise catalog (ids differ per device/account) +speediance-cli library # writes library.json: {id, name, muscle, tab} +speediance-cli library --search "row" --json +``` + +A committed `library.json` snapshot ships with the repo (Gym Monster 1); regenerate it +with the command above for the freshest catalog or a different device. + +```bash +# 2) write a plan JSON (you, the agent, author this), then: +speediance-cli push plan.json --dry-run # preview payload, no network write +speediance-cli push plan.json # create it on the account +``` + +### Plan JSON + +```json +{ + "name": "Pull Day", + "exercises": [ + {"id": 434, "title": "Seated Dual-Handle Lat Pulldown", + "sets": [{"reps": 12, "weight": 20, "mode": 1, "rest": 75}]} + ] +} +``` + +- `id` β€” from `library.json` +- `weight` β€” **kilograms** (stored internally as `kg Γ— 2.2`; confirm the displayed + unit on the machine on first use and adjust if needed) +- `mode` β€” 1 Standard, 2 Eccentric, 3 Isokinetic, 4 Constant, 5 Spotter +- `rest` β€” seconds + +Always `--dry-run` first when authoring new programs to confirm exercise ids resolved. + +## 5. Storing what you read (it's the caller's job) + +The CLI does not own a log format. To keep a record of a session, **pull** it with +`workouts --days N --json` and `session --json`, then write it wherever you +keep data (a Markdown sheet, a database, a notebook). The tool reads and emits; the caller +decides the layout. Note that freestyle **"Free Lift"** sessions return totals only β€” no +per-set detail to store. + +## Conventions + +- **stdout is parseable** with `--json`; human hints, warnings, and logs go to **stderr**. + They are never interleaved, so piping stdout into a parser is safe. +- **Exit codes:** `0` success, `2` auth failure, non-zero for other errors. Check them. +- **No `doctor`/health command β€” by design.** To diagnose setup programmatically, read + `config show --json` (what resolved, where) and run `login` (exit `2` = auth/connectivity + failure; it rewrites the token cache). Don't go looking for a single health command β€” chain + those instead. +- **Token cache:** the session token is cached in the OS user-**cache** dir by default + (non-roaming; `config path` shows the resolved location), **not** the working directory + (so running the CLI from another repo can't drop a credential into it) and **not** the + roaming config dir (so a live token isn't synced across machines). Override with + `SPEEDIANCE_TOKEN_CACHE` or the `token_cache_path` config key. +- **Secrets:** `config.json`, `.token.json`, `.env` are gitignored. Never commit them. +- **Device:** tested for Gym Monster 1 only; GM2 untested. +- **Unofficial API:** all endpoints live in `internal/api`; if the Speediance app updates + and something breaks, that's the single place to patch. +- Branch `main` is PR-protected β€” changes land via pull request. + +## Command surface + +| Command | Purpose | `--json` | +|---|---|---| +| `login` | authenticate, cache token | β€” | +| `workouts --days N` | list recent sessions | yes | +| `today [--date D]` | every session on a day, auto-resolved to type-correct detail | yes | +| `session [--free\|--program]` | full, verbatim detail for one session; auto-detects type | yes | +| `library` | dump exercise catalog to `library.json` | yes | +| `push ` | create a program (`--dry-run` to preview) | yes | +| `config show\|set\|path` | manage `config.json` | yes (`show`) | +| `version` | build metadata (also `--version`) | yes | +| `completion ` | shell completion script | β€” | diff --git a/docs/OPERATING_INSTRUCTIONS.md b/docs/OPERATING_INSTRUCTIONS.md new file mode 100644 index 0000000..502fee3 --- /dev/null +++ b/docs/OPERATING_INSTRUCTIONS.md @@ -0,0 +1,21 @@ +# Operating instructions + +Treat "can you...", "I want to...", and "help me..." as requests to complete the authorized work. Infer scope from the request and prior context. Finish implementation and required verification instead of stopping at a plan or offering to continue. + +Authorization persists across turns. Prepare a concrete, reviewable result before asking about a remaining unapproved action. Ask only for material missing information and continue independent work while waiting. Silence is not approval. + +Respect explicit limits, product decisions, required reviews, destructive-action boundaries, and tool permissions. Preserve uncommitted work. User instructions take precedence over project skill guidelines, subject to system and developer instructions. Commits, pushes, messages, merges, releases, and deployments require authorization appropriate to their effect. + +If an instruction blocks progress, link its exact source, quote it, and explain why it applies. Distinguish a rule from your interpretation. Report enforced tool rejections accurately. + +Read the affected flow before editing. Reuse existing code and tools. Scope reviews and tests to changed and affected paths, complete required checks, and broaden or repeat only for new changes, failures, or unresolved concerns. Verify actual artifacts and remote state as well as the relevant command's exit code. Report skipped checks honestly. + +Communicate concise findings and evidence. Incorporate corrections and answer side questions without abandoning the active task unless the user cancels or replaces it. Keep a checkpoint for sustained work. + +## Shared instructions and skills + +Edit shared project documents, not separate agent policies. Root AGENTS.md and CLAUDE.md must remain byte-identical pointers. Cursor loads the same documents through `.cursor/rules/project-instructions.mdc`. + +Keep complete skill packages in `.claude/skills`, `.cursor/skills`, and `.codex/skills`. Read and reconcile useful content from every existing copy before repair; neither a majority nor a provider is automatically authoritative. Bytes must match except references to each copy's own skills tree, in either slash format. Derive helper paths from the current checkout. Keep required guidance local. Preserve provider-specific settings, hooks, and adapters in native formats. + +Before a PR, run `python scripts/sync-harness-skills.py --check`. If the checker changes, also run `python scripts/test-sync-harness-skills.py`. Repair only after reconciliation with `python scripts/sync-harness-skills.py --fix --from ` and inspect the resulting diff. Empty skill directories use identical `.gitkeep` files; do not invent skills to fill them. diff --git a/docs/PROJECT_INSTRUCTIONS.md b/docs/PROJECT_INSTRUCTIONS.md new file mode 100644 index 0000000..39736c7 --- /dev/null +++ b/docs/PROJECT_INSTRUCTIONS.md @@ -0,0 +1,99 @@ +# Speediance project instructions + +Developer-facing brief for working **on** `speediance-cli` (a single-binary Go +CLI for the unofficial Speediance / Gym Monster cloud API). For *using* the CLI +from an agent see `docs/MACHINE_CONTRACT.md`; for the design contract see `GOAL.md`. + +## MANDATORY β€” ClawHub security standards + +Before changing anything that touches **credential handling, configuration +resolution, environment/`.env` loading, file writes, network calls, logging +output, or `SKILL.md`**, and before **publishing the skill to ClawHub**, you MUST +read and follow `.claude/CLAWHUB_STANDARDS.md`. + +Read the local standards in full; their rules and pre-publish checklist are binding. +Pin every new security behavior with an immutable regression test as described there. + +Read [.claude/CLAWHUB_STANDARDS.md](../.claude/CLAWHUB_STANDARDS.md). + +## MANDATORY β€” shared CLI conventions + +`speediance-cli` shares its config/auth/credential layer design with its sibling +`google-health-cli`. The cross-repo invariants for that layer (per-user/non-roaming +secret locations, `0600`/`0700` perms, advertised==actual, conservative migration, +`.env` no-inject, …) live in `.claude/CLI_CONVENTIONS.md`, committed **byte-identical** +in both repos. Changes go through the shared agent process so both copies stay in sync β€” +do not edit one repo's copy unilaterally. Read the local copy in full in every harness. + +Read [.claude/CLI_CONVENTIONS.md](../.claude/CLI_CONVENTIONS.md). + +## Build & verify + +- `go build ./... && go vet ./... && go test ./...` must pass; `gofmt -l` must be clean. +- `main` is PR-protected β€” land changes via pull request. + +## Local reference data β€” `REAL-SPEEDIANCE-JSON/` + +If present on the working machine, `REAL-SPEEDIANCE-JSON/` holds **real captured +Speediance session JSON** from a live account β€” the ground-truth shapes of actual +CLI output / API responses per session `kind`/`type` (program, freestyle Free Lift, +guided rowing, the `today` array, …). See its `README.md` for an index. + +- It is **gitignored on purpose** β€” personal workout data in a public repo. **Never + commit it, never move it back into a tracked path, and never paste its contents + into a PR, issue, commit, or published doc.** It is local reference only. +- **Prefer it over fabricating samples.** When changing session/dispatch code or + reasoning about the `--json` contract, read a real file here instead of inventing a + shape. Save any new live captures into this folder. +- It may be **absent** (a fresh clone / CI / another machine) β€” that's expected; the + test fixtures (e.g. the genuine `940759` data embedded in tests) are the committed, + CI-visible source of truth. This folder is a convenience, never a dependency. + +## PR gate β€” guard tests are mandatory and immutable + +A PR may not be opened or merged unless `go build ./... && go vet ./... && go test -race ./...` +pass and `gofmt -l` is clean. This is **enforced, not advisory**: CI +(`.github/workflows/ci.yml`) runs build + `go test -race` + lint on every `pull_request`. + +The **negative-assertion guard tests** β€” every test named in the SPD cells of +`.claude/CLI_CONVENTIONS.md` (Β§0, Β§1, Β§3, Β§5, Β§7, Β§9) **plus** +`internal/cli`'s `TestEndToEndMigratesLegacyTokenToCacheDir` β€” are **immutable**: each +asserts that a known bad thing does **not** happen (a secret in CWD, a token in the +roaming base, a `.env` mutating the process env, an advertised-but-unwired key, …). They +must never be skipped (`t.Skip`), deleted, or weakened to turn a PR green β€” a red guard +means **fix the code, not the test**. Any new credential / config / permission / network +behavior ships with its guard in the **same** PR (see `.claude/CLAWHUB_STANDARDS.md`). + +## Commits & releases + +Commit subjects follow [Conventional Commits](https://www.conventionalcommits.org) +(`feat:`, `fix:`, `docs:`, `test:`, `chore:`) β€” this is **load-bearing, not cosmetic**. +Releases are cut by pushing a `vX.Y.Z` git tag (never by merging), and GoReleaser +auto-builds the GitHub Release notes by **grouping commit subjects** (`feat:` β†’ Features, +`fix:` β†’ Bug fixes, else β†’ Other changes; `docs:`/`test:`/`chore:` excluded) plus a static +install footer β€” there is **no `CHANGELOG.md`**. Use the right prefix so the changelog groups +cleanly, and squash-merge PRs with a clean Conventional-Commit title. Full release playbook +(versioning, tagging, dry-runs): `RELEASING.md`. + +## Scope β€” don't add a `doctor`/health command + +The diagnostic surface is **intentionally** spread across existing commands, not bundled +into a `doctor`: `version` (install/build), `config show` + `config path` (resolved config +and file locations), and `login` (auth + connectivity; exit `2` on failure). With a single +external dependency (the Speediance API) and an agent-first consumer that prefers `--json` ++ exit codes over a human-readable health report, a `doctor` aggregator is speculative +surface that cuts against the minimal-command philosophy (GOAL.md). Revisit only if +human-user support load makes a read-only aggregator clearly worth its weight. + +## Shared harness maintenance + +Run `make check-agents` for instruction and skill changes (Python 3 is a developer +check dependency only; the shipped CLI remains a single binary). Root `SKILL.md` +is the published artifact; mirror its complete package into each harness after +editing it. Reconcile all copies before choosing a repair source. Run +`python scripts/sync-harness-skills.py --fix --from claude`, or select `cursor`/`codex`, +then `make check-agents`. Preserve native settings and publishing triggers. + +Read `CONTRIBUTING.md`, `SECURITY.md`, and `RELEASING.md` for their applicable gates. +`GOAL.md` retains historical rewrite decisions; its removed sync/sheet surface is +not a request to restore those features. Paths in this document are repository-relative. diff --git a/docs/project-agent-audit.md b/docs/project-agent-audit.md new file mode 100644 index 0000000..ba1548e --- /dev/null +++ b/docs/project-agent-audit.md @@ -0,0 +1,63 @@ +# Project agent audit β€” 2026-09-06 + +## Scope and findings + +Audited tracked root instructions, both local standards, contributor/security/release +instructions, the historical GOAL, root SKILL.md, Makefile, CI, and release packaging. +Excluded private reference data, dependencies, build output, and nested worktrees. +The original checkout is preserved; this branch starts at origin/main 7857392. +The existing setup-documentation PR #26 and dependency PRs are separate work and remain untouched. + +## Changes and reconciliation + +- Preserved the full agent CLI contract in docs/MACHINE_CONTRACT.md and contributor + guidance in docs/PROJECT_INSTRUCTIONS.md. Identical root pointers and Cursor's + alwaysApply project rule load the same shared operating and project guidance. +- Applied authorized-action persistence, material-only clarification, concrete approval, + explicit boundaries, scoped verification, artifact proof, and honest blocker/skip reporting + from the model guide: https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra. +- The root published SKILL.md is the only existing skill package. Copied it byte-for-byte + to all three harnesses (one file each), preserving its permissions and product contract. + There were no competing copies, helpers, or provider settings to reconcile. Keep the + root publication artifact and mirrors synchronized on future edits. +- Reused OpenLoop PR #176's checker and 16 regression cases; generalized its introduction + and remote-default fallback only. make check-agents participates in make check and CI. + It covers missing/deleted/untracked files, drift, identical foreign references in both + slash forms, CRLF preservation, source-preserving repair, and ambiguity refusal. +- Retained both .claude standards at their established paths as shared local documents; + all harnesses explicitly read them. CLI_CONVENTIONS.md remains byte-identical to its + sibling copy; it requires no read from another checkout. Its historical @import + description now routes through the root pointer to explicit shared-document reads. +- Preserved frozen API/JSON semantics, credential privacy, immutable security tests, + no-doctor decision, GM1-only guarantee, Conventional Commits, and release/publish gates. + Fixed the release-playbook filename's case in the relocated contributor document. +- Included the pointer targets and referenced policy docs in GoReleaser archives, and + the SKILL.md already promised by its footer. Publishing triggers are unchanged. +- The required make check formatter adjusted two Go call layouts in config.go and + config_test.go; no runtime logic or test assertion changed. + +## Validation + +- make check passed: mirror parity, all 16 checker regression cases, tidy, formatting, + vet, golangci-lint (0 issues), and race tests (7 passing packages; 1 has no tests). +- go build ./... and make build passed; the built binary exists and is nonempty. +- Root pointers are byte-identical; root SKILL.md matches all three skill copies. +- goreleaser check validated the configuration. Final diff and whitespace reviewed. + +## Skipped and limitations + +No live account login, workout retrieval, program creation, ClawHub publication, +release, deployment, or full cross-platform archive build was performed. Runtime and +account behavior is unchanged; fixture/race tests cover the existing security guards. +Windows Unix-permission assertions remain platform-inapplicable where the existing +suite says so; Linux CI runs them. Setup/credential ordering in the unchanged published +skill remains tracked by existing PR #26; this audit does not duplicate its rewrite. + +## Final CI follow-up + +Linux build/race tests passed. CI lint initially failed because stable advanced to +Go 1.27.1 while the existing v2.12.2 linter was built with Go 1.26 and could not +parse the new standard library. Pinned only the lint lane to 1.26.x, matching the +locally verified Go 1.26.4/linter v2.12.2 pair. Build/race tests still use stable; +all lint rules and guards are unchanged. Updated the release playbook to keep +the pair explicit. This fixes the check environment without suppressing a finding. diff --git a/internal/config/config.go b/internal/config/config.go index 3b996b1..02ce2b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -291,7 +291,8 @@ func (c *Config) RequireCredentials() error { "or add \"email\"/\"password\" to config.json. config.json is resolved from "+ "--config / %s, then ./%s, then /%s/%s (resolved this run: %s)", ErrMissingCredentials, EnvEmail, EnvPassword, - EnvConfig, defaultConfigName, appUserSubdir, defaultConfigName, c.ConfigPath) + EnvConfig, defaultConfigName, appUserSubdir, defaultConfigName, c.ConfigPath, + ) } // DeviceWarning returns a non-empty warning string when a non-GM1 device is diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0ed85c1..7ca4447 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -331,7 +331,8 @@ func TestDotEnvDoesNotInjectForeignEnv(t *testing.T) { "SPEEDIANCE_EMAIL=ok@b.com\n"+ "LD_PRELOAD=/tmp/evil.so\n"+ "PATH=/evil/bin\n"+ - pathProbe+"=injected\n"), 0o600); err != nil { + pathProbe+"=injected\n", + ), 0o600); err != nil { t.Fatal(err) } diff --git a/scripts/sync-harness-skills.py b/scripts/sync-harness-skills.py new file mode 100644 index 0000000..44f411c --- /dev/null +++ b/scripts/sync-harness-skills.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Check complete project skill packages across Claude, Cursor, and Codex. + +Based on OpenLoop PR #176. Only own-skill-tree paths may differ. +Run --check before a PR. Reconcile all copies before --fix --from . +Provider settings and adapters outside skills stay in their native formats. +Requires Python 3.10+ and Git. Repair preserves source bytes and retargets paths. +""" +import argparse +import filecmp +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +HARNESSES = ("claude", "cursor", "codex") +SUBTREE = "skills" + +# A reference to something INSIDE the harness's own skills tree, in either slash flavor β€” the +# skills are written on Windows and quote both `.claude/skills/verify-openloop/...` and +# `pwsh \.claude\skills\run-e2e\...`. A deeper path is required so that prose enumerating +# the three trees ("`.claude/skills/`, `.cursor/skills/`, `.codex/skills/`", which harness-sync's +# own SKILL.md does) is left alone: that is a list of all three, not a pointer at one. +SELF_REF = re.compile(r"\.(claude|cursor|codex)([/\\]" + SUBTREE + r"[/\\][A-Za-z0-9_.-])") +SELF_TOKEN = "." + + +def normalized(path, harness): + """File text with a reference to `harness`'s own skills tree collapsed to one token. + + Only that harness's token is replaced, so each copy has to point at ITSELF to compare equal: + `.cursor`'s copy naming `.codex/skills` normalizes to nothing and reads as drift, which is + what it is. None means "not UTF-8 text" β€” those are compared byte-for-byte instead. + """ + try: + # newline="": no universal-newline translation. Without it a CRLF skill file would come + # back with LF endings and --fix would rewrite every line of it as a "sync". + with path.open(encoding="utf-8", newline="") as fh: + text = fh.read() + except (UnicodeDecodeError, OSError): + return None + return re.sub(rf"\.{harness}([/\\]{SUBTREE}[/\\][A-Za-z0-9_.-])", SELF_TOKEN + r"\1", text) + + +def same_content(a, a_harness, b, b_harness): + """True when two copies differ only in which harness tree they point at.""" + na = normalized(a, a_harness) + if na is not None: + return na == normalized(b, b_harness) + return filecmp.cmp(a, b, shallow=False) + + +def tracked(harness): + """Relative paths under /skills that git can see, mapped to their absolute path. + + `--cached --others --exclude-standard` is tracked files PLUS untracked ones that are not + gitignored. Both halves matter: a directory walk would drag in gitignored working files + (`.claude/worktrees/` above all) and read them as drift, while tracked-only would miss a + brand-new skill that has not been `git add`ed yet β€” the case where drift is most likely, + since a new skill written into one harness is exactly what needs propagating. + """ + top = f".{harness}/{SUBTREE}" + out = subprocess.run(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", top], + cwd=ROOT, check=True, capture_output=True, text=True).stdout + return {p[len(top) + 1:]: ROOT / p for p in out.split("\0") if p} + + +def edited_trees(rel_paths): + """Harnesses whose copy of one of the DRIFTED paths changed β€” committed here, or still dirty. + + This is the safety net under `--fix`. Aligning three trees is destructive by nature: two of + them get overwritten, and if the wrong one is named as the source the edit being propagated is + silently deleted and the check then passes, so nothing ever reports the loss. The person or + agent running the command is the least reliable source for "which one did I edit"; git knows. + + Scoped to the paths that actually differ, so an unrelated edit elsewhere in a tree cannot vote + on the direction for this one. Git has nothing to say about a path that is untracked in all + three harnesses (a brand-new skill and the copies a previous --fix wrote look identical to it), + and the caller reports that case rather than picking a winner from no evidence. + """ + default = subprocess.run(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + base = subprocess.run(["git", "merge-base", default or "origin/main", "HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + paths = [f".{h}/{SUBTREE}/{rel}" for h in HARNESSES for rel in rel_paths] + changed = set() + # Committed on this branch (skipped when there is no origin/main to compare against, e.g. a + # fresh clone or a test fixture) plus anything uncommitted in the working tree. + cmds = ([["git", "diff", "--name-only", base, "--", *paths]] if base else []) + \ + [["git", "status", "--porcelain", "--", *paths]] + for cmd in cmds: + out = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True).stdout + for line in out.splitlines(): + line = line[3:] if cmd[1] == "status" else line # strip porcelain XY prefix + for h in HARNESSES: + if line.strip().strip('"').startswith(f".{h}/{SUBTREE}/"): + changed.add(h) + return changed + + +def drift(): + """(rel_path, {harness: 'same'|'differs'|'missing'}) for every path that is not identical.""" + trees = {h: tracked(h) for h in HARNESSES} + base = HARNESSES[0] + found = [] + for rel in sorted(set().union(*(t.keys() for t in trees.values()))): + state = {} + for h in HARNESSES: + # `git ls-files` still lists a tracked file that has been deleted on disk, so + # existence is checked separately β€” an unstaged delete is drift like any other. + if rel not in trees[h] or not trees[h][rel].exists(): + state[h] = "missing" + elif rel not in trees[base] or not trees[base][rel].exists() or h == base: + state[h] = "same" + else: + state[h] = "same" if same_content(trees[base][rel], base, trees[h][rel], h) else "differs" + # All-missing is consistent, not drift: git still lists a path deleted from every + # harness (staged or not), and flagging that would leave --fix with nothing to do + # and the gate permanently red until the delete was committed. + if set(state.values()) not in ({"same"}, {"missing"}): + found.append((rel, state)) + return trees, found + + +def main(argv): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--check", action="store_true", help="exit 1 if the trees differ (default)") + ap.add_argument("--fix", action="store_true", help="copy --from over the other harnesses") + ap.add_argument("--from", dest="source", choices=HARNESSES, + help="the harness that was edited; inferred from git when omitted") + ap.add_argument("--force", action="store_true", + help="proceed even though --from contradicts git β€” discards that change") + args = ap.parse_args(argv) + + trees, found = drift() + total = len(trees[HARNESSES[0]]) + + if not args.fix: + if not found: + print(f"harness skills in sync ({total} files x {len(HARNESSES)} harnesses)") + return 0 + print(f"{len(found)} path(s) differ between {', '.join('.' + h for h in HARNESSES)}:") + for rel, state in found: + print(f" {SUBTREE}/{rel}") + for h in HARNESSES: + print(f" .{h}: {state[h]}") + print("\nThe harness you edited is the source of truth. Propagate it with:") + print(" python scripts/sync-harness-skills.py --fix --from ") + return 1 + + if not found: + # Checked before any source is worked out: with nothing to propagate there is no source to + # get wrong, and three freshly-synced trees would otherwise look like three separate edits. + print(f"harness skills already in sync ({total} files x {len(HARNESSES)} harnesses)") + return 0 + + # Align means the edited harness wins and the other two catch up β€” never the reverse. Git is + # the authority on which one was edited, so a declared --from that contradicts it is refused + # rather than obeyed: that combination is precisely the one that deletes the change. + edited = edited_trees([rel for rel, _ in found]) + if not args.source: + if len(edited) == 1: + args.source = next(iter(edited)) + print(f"source: .{args.source} (the only harness with changes; use --from to override)\n") + else: + if not edited: + why = ("git sees no change to the drifted path(s) in any harness β€” nothing to " + "propagate, so the drift came from somewhere else") + elif len(edited) == len(HARNESSES): + why = ("every copy is new or modified, which is what a fresh skill plus the copies " + "a previous --fix wrote look like. Git cannot tell an edit from a sync here, " + "so name the harness you edited with --from") + else: + why = (f"{len(edited)} harnesses were edited separately " + f"({', '.join('.' + h for h in sorted(edited))}). Reconcile them into one " + "correct tree by hand, then propagate from that one") + print(f"cannot infer the source: {why}.") + return 2 + elif edited and args.source not in edited: + print(f"refusing: you named .{args.source} as the source, but git says the change is in " + f"{', '.join('.' + h for h in sorted(edited))}.") + print(f"Syncing from .{args.source} would overwrite that change and delete it. If you really " + f"mean to discard it, pass --force.") + if not args.force: + return 2 + print("--force given; continuing.\n") + + src_tree = trees[args.source] + copied = removed = 0 + for rel, _ in found: + for h in HARNESSES: + if h == args.source: + continue + dst = ROOT / f".{h}" / SUBTREE / rel + if rel not in src_tree or not src_tree[rel].exists(): + # Deleted in the source: mirror the delete, or the next --check flags it forever. + if dst.exists(): + dst.unlink() + removed += 1 + print(f" removed .{h}/{SUBTREE}/{rel}") + continue + dst.parent.mkdir(parents=True, exist_ok=True) + # Retarget the source's self-reference at the harness being written, so its copy points + # at a tree that LLM can actually read. Everything else is copied verbatim; a non-text + # file (or one with no self-reference) round-trips byte-for-byte through the same path. + text = normalized(src_tree[rel], args.source) + if text is None: + shutil.copyfile(src_tree[rel], dst) + else: + dst.write_text(text.replace(SELF_TOKEN, f".{h}"), encoding="utf-8", newline="") + copied += 1 + print(f" wrote .{h}/{SUBTREE}/{rel}") + print(f"\nsynced from .{args.source}: {copied} copied, {removed} removed β€” `git add` the result") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test-sync-harness-skills.py b/scripts/test-sync-harness-skills.py new file mode 100644 index 0000000..33759f0 --- /dev/null +++ b/scripts/test-sync-harness-skills.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Self-check for scripts/sync-harness-skills.py β€” the project skill synchronization gate. + + python scripts/test-sync-harness-skills.py + +Every case builds a throwaway git repo in a temp directory and copies the real script into it, +so nothing here can touch the actual harness trees. That matters more than usual: this script's +`--fix` overwrites files, and a test that ran in-repo could leave `.cursor/skills/` dirty and red +the very gate it is checking. + +No framework on purpose β€” asserts and a `main()`. What each case pins down is a way the gate has +already been wrong or could silently become useless: + + * in_sync β€” the happy path returns 0, or the gate cries wolf on every commit. + * content_drift β€” the case the gate exists for. + * missing_file β€” a skill present in one harness and absent in another. + * untracked_file β€” a NEW skill not yet `git add`ed. This was a real hole: the file set came + from tracked files only, so writing a new skill into one harness left the + gate green and the other two harnesses permanently behind. + * deleted_on_disk β€” tracked by git but removed from the working tree; `git ls-files` still + lists it, so existence has to be checked separately (this one crashed). + * fix_propagates β€” --fix --from actually repairs, and leaves the source untouched. + * fix_mirrors_deleteβ€” a delete in the source is mirrored, not silently re-created forever. + * fix_needs_source β€” bare --fix refuses rather than guessing, because a wrong guess reverts + the edit being propagated and then passes the check, hiding the loss. + * self_reference β€” the one allowed difference: each copy points at its OWN skills tree. The + three cases below pin all of it, because the normalization that permits it + is also the way real drift could start slipping past the gate. +""" +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent / "sync-harness-skills.py" +HARNESSES = ("claude", "cursor", "codex") +SKILL = "skills/demo/SKILL.md" + + +def build_repo(tmp): + """A minimal repo: three harness trees holding one identical skill, all committed.""" + subprocess.run(["git", "init", "-q"], cwd=tmp, check=True) + (tmp / "scripts").mkdir() + shutil.copyfile(SCRIPT, tmp / "scripts" / SCRIPT.name) + for h in HARNESSES: + dst = tmp / f".{h}" / SKILL + dst.parent.mkdir(parents=True) + dst.write_text("# demo skill\n", encoding="utf-8") + # A gitignored file inside a harness tree must never read as drift. + (tmp / ".gitignore").write_text(".claude/worktrees/\n", encoding="utf-8") + (tmp / ".claude" / "worktrees").mkdir(parents=True) + (tmp / ".claude" / "worktrees" / "junk.md").write_text("ignore me\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=tmp, check=True) + subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", + "commit", "-qm", "init"], cwd=tmp, check=True) + + +def run(tmp, *args): + p = subprocess.run([sys.executable, str(tmp / "scripts" / SCRIPT.name), *args], + cwd=tmp, capture_output=True, text=True) + return p.returncode, p.stdout + p.stderr + + +def case(name): + """Fresh repo per case, torn down after β€” cases must not inherit each other's drift.""" + def decorator(fn): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + build_repo(tmp) + fn(tmp) + print(f" ok {name}") + return decorator + + +def main(): + print(f"testing {SCRIPT.name}") + + @case("in_sync β€” clean trees exit 0 and ignore gitignored files") + def _(tmp): + code, out = run(tmp) + assert code == 0, out + assert "in sync" in out, out + assert "worktrees" not in out, f"gitignored file leaked into the comparison:\n{out}" + + @case("content_drift β€” one edited copy is reported, naming the harness") + def _(tmp): + (tmp / ".cursor" / SKILL).write_text("# demo skill\nedited\n", encoding="utf-8") + code, out = run(tmp) + assert code == 1, out + assert SKILL in out and "differs" in out, out + + @case("missing_file β€” a skill absent from one harness is drift") + def _(tmp): + (tmp / ".codex" / SKILL).unlink() + subprocess.run(["git", "add", "-A"], cwd=tmp, check=True) + code, out = run(tmp) + assert code == 1 and "missing" in out, out + + @case("untracked_file β€” a NEW skill not yet git-added is still drift") + def _(tmp): + new = tmp / ".claude" / "skills" / "fresh" / "SKILL.md" + new.parent.mkdir(parents=True) + new.write_text("# brand new\n", encoding="utf-8") + code, out = run(tmp) + assert code == 1, f"a new unsynced skill must not pass the gate:\n{out}" + assert "fresh" in out, out + + @case("deleted_on_disk β€” tracked but removed still counts, and does not crash") + def _(tmp): + (tmp / ".codex" / SKILL).unlink() # deliberately NOT git rm'd + code, out = run(tmp) + assert code == 1 and "missing" in out, out + assert "Traceback" not in out, out + + @case("fix_propagates β€” --from repairs the others and leaves the source alone") + def _(tmp): + (tmp / ".cursor" / SKILL).write_text("# demo skill\nthe good edit\n", encoding="utf-8") + code, out = run(tmp, "--fix", "--from", "cursor") + assert code == 0, out + for h in HARNESSES: + assert "the good edit" in (tmp / f".{h}" / SKILL).read_text(encoding="utf-8"), h + assert run(tmp)[0] == 0 + + @case("fix_mirrors_delete β€” a delete in the source removes the copies") + def _(tmp): + (tmp / ".claude" / SKILL).unlink() + code, out = run(tmp, "--fix", "--from", "claude") + assert code == 0, out + for h in HARNESSES: + assert not (tmp / f".{h}" / SKILL).exists(), h + assert run(tmp)[0] == 0 + + @case("fix_infers_source β€” bare --fix propagates FROM the tree git says changed") + def _(tmp): + (tmp / ".cursor" / SKILL).write_text("# demo skill\nedited\n", encoding="utf-8") + code, out = run(tmp, "--fix") + assert code == 0, out + assert ".cursor" in out, out + # Aligning must carry the edit outward, never erase it to match the untouched majority. + for h in HARNESSES: + assert "edited" in (tmp / f".{h}" / SKILL).read_text(encoding="utf-8"), h + + @case("fix_refuses_contradiction β€” --from that git disagrees with is blocked, not obeyed") + def _(tmp): + (tmp / ".cursor" / SKILL).write_text("# demo skill\nedited\n", encoding="utf-8") + code, out = run(tmp, "--fix", "--from", "claude") + assert code == 2, out + assert "delete" in out and ".cursor" in out, out + # The whole point: the edit survives the refusal. + assert "edited" in (tmp / ".cursor" / SKILL).read_text(encoding="utf-8"), out + + @case("fix_force_overrides β€” --force still allows a deliberate discard") + def _(tmp): + (tmp / ".cursor" / SKILL).write_text("# demo skill\nedited\n", encoding="utf-8") + code, out = run(tmp, "--fix", "--from", "claude", "--force") + assert code == 0, out + assert "edited" not in (tmp / ".cursor" / SKILL).read_text(encoding="utf-8") + + @case("fix_refuses_when_ambiguous β€” two edited trees are reconciled by a human, not by us") + def _(tmp): + (tmp / ".cursor" / SKILL).write_text("# demo skill\ncursor edit\n", encoding="utf-8") + (tmp / ".codex" / SKILL).write_text("# demo skill\ncodex edit\n", encoding="utf-8") + code, out = run(tmp, "--fix") + assert code == 2, out + assert "cursor edit" in (tmp / ".cursor" / SKILL).read_text(encoding="utf-8") + assert "codex edit" in (tmp / ".codex" / SKILL).read_text(encoding="utf-8") + + @case("self_reference β€” each copy pointing at its own skills tree is not drift") + def _(tmp): + for h in HARNESSES: + (tmp / f".{h}" / SKILL).write_text( + f"# demo skill\nrun .{h}/skills/demo/go.py and pwsh .{h}\\skills\\demo\\go.ps1\n", + encoding="utf-8") + code, out = run(tmp) + assert code == 0, f"self-references must be allowed in both slash flavors:\n{out}" + + @case("self_reference_wrong_target β€” pointing at ANOTHER harness's tree is still drift") + def _(tmp): + # The hole this closes: normalizing every harness token to one placeholder would make + # `.codex/skills` inside .cursor's copy compare equal, and Cursor would be sent to a + # directory it cannot read with the gate green. + (tmp / ".cursor" / SKILL).write_text("# demo skill\nrun .codex/skills/demo/go.py\n", + encoding="utf-8") + (tmp / ".claude" / SKILL).write_text("# demo skill\nrun .claude/skills/demo/go.py\n", + encoding="utf-8") + (tmp / ".codex" / SKILL).write_text("# demo skill\nrun .codex/skills/demo/go.py\n", + encoding="utf-8") + assert run(tmp)[0] == 1, "a copy pointing at someone else's tree must be reported" + + @case("fix_retargets β€” --fix rewrites the self-reference for each destination harness") + def _(tmp): + (tmp / ".claude" / SKILL).write_text( + "# demo skill\nread .claude/skills/demo/note.md\n", encoding="utf-8") + code, out = run(tmp, "--fix", "--from", "claude") + assert code == 0, out + for h in HARNESSES: + body = (tmp / f".{h}" / SKILL).read_text(encoding="utf-8") + assert f".{h}/skills/demo/note.md" in body, f".{h} was not retargeted: {body!r}" + assert run(tmp)[0] == 0, "a retargeted tree must satisfy the check it was written for" + + @case("identical_wrong_targets β€” identical bytes must not hide foreign harness paths") + def _(tmp): + for h in HARNESSES: + (tmp / f".{h}" / SKILL).write_text( + "# demo skill\nread .claude/skills/demo/note.md and .claude\\skills\\demo\\go.ps1\n", + encoding="utf-8") + code, out = run(tmp) + assert code == 1, f"identical foreign paths must fail the check:\n{out}" + code, out = run(tmp, "--fix", "--from", "claude") + assert code == 0, out + for h in HARNESSES: + body = (tmp / f".{h}" / SKILL).read_text(encoding="utf-8") + assert f".{h}/skills/demo/note.md" in body, body + assert f".{h}\\skills\\demo\\go.ps1" in body, body + assert run(tmp)[0] == 0, "repair must satisfy the check" + + @case("fix_preserves_crlf β€” a CRLF skill is not silently rewritten to LF") + def _(tmp): + (tmp / ".claude" / SKILL).write_bytes(b"# demo skill\r\nedited\r\n") + assert run(tmp, "--fix", "--from", "claude")[0] == 0 + for h in HARNESSES: + assert (tmp / f".{h}" / SKILL).read_bytes() == b"# demo skill\r\nedited\r\n", h + + print("all cases passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())