⚠️ Historical spec — partially superseded. After the Go rewrite shipped, thesynccommand and theinternal/sheet/package (plus theweeks_dir/unitconfig keys andSPEEDIANCE_WEEKS_DIRenv var) were removed to make the tool a pure data collector: it now only reads and emits data and pushes programs — it owns no downstream log layout. The MarkdownWEEKS/Week-XX.mdwriting thatsyncused to do moved into the consuming agent (personal-workout-ai). Sections below that describesync/sheet/weeks_dirdocument the original port and no longer reflect the shipped command surface; treat them as history. SeeREADME.md/AGENTS.md/SKILL.mdfor the current surface.
One-line objective: Reimplement the Python
speediance-clias a single, statically-compiled, idiomatic Go CLI that is a drop-in replacement for the existing tool — same commands, same credentials, same API behavior, same--jsonoutput — while modernizing the internals, error handling, and distribution so it's a high-quality, maintainable open-source project for the OpenClaw / agent community.
This document is the specification to build against. It is grounded in current (2025/2026) Go best practices (sources in §20). The author is not a Go developer, so the resulting code and docs must be idiomatic and self-explanatory enough for a non-Go maintainer to keep healthy.
speediance-cli is a ~800-line Python package that wraps the reverse-engineered Speediance
(Gym Monster) cloud API. It lets a human or an AI agent read completed workouts and push custom
training programs that appear on the machine. It ships today as:
- a
pip install git+…package (entry pointspeediance-cli, alsopython -m speediance), and - a ClawHub skill (
SKILL.md) auto-published by a GitHub Action — installed and driven by OpenClaw / Claude-style agents.
Because agents and the published skill consume this tool, the external contract is load-bearing:
other people's automation parses our --json stdout, and the Speediance servers reject anything
whose request payloads/headers aren't byte-for-byte what the Android app sends.
| File | Responsibility |
|---|---|
speediance/cli.py |
argparse CLI: login, workouts, session, library, push, sync |
speediance/client.py |
HTTP client (requests): auth, token refresh, endpoints, region base URLs |
speediance/config.py |
config JSON + env-var overrides; token cache file (0600) |
speediance/models.py |
Workout, SetData dataclasses; record parsing; timestamp normalization |
speediance/templates.py |
build/push customTrainingTemplate payload; fetch exercise library; KG_TO_API |
speediance/sheet.py |
optional Markdown WEEKS/Week-XX.md sync with fuzzy exercise matching |
tests/ |
two offline (no-network) assert scripts |
pyproject.toml, SKILL.md, .github/workflows/publish-clawhub.yml |
packaging + skill publish |
These hold regardless of how freely we "modernize" elsewhere.
- API wire format is frozen. Every request URL, query param, JSON body field name, CSV-encoded value, and HTTP header must be byte-identical to the Python client. The server is an unofficial, brittle target; deviation breaks auth or program creation. See §8 and §10.
--jsonstdout is backward-compatible. Field names, nesting, and types of all--jsonoutput match the Python tool exactly (field order preserved as a courtesy). Existing agents and the ClawHub skill must keep working with zero changes. See per-command schemas in §9.- stdout = data, stderr = everything else. Machine-readable JSON only on stdout; all human hints, progress, warnings, and logs go to stderr. Never interleave. See §13.
- Drop-in credentials. Same env vars and the same
config.json/.token.jsondiscovery, so a current user (who already has a cached token) doesn't have to reconfigure. See §7. - Exit codes preserved. Auth failure → exit
2; missing-credentials / missing---weeks-dir→ non-zero hard error; success →0. See §12.
Everything not on this list — internal structure, error wording, retries, logging, new convenience commands — is fair game to improve.
- No new device support guarantees. Gym Monster 1 (
device_type = 1) is the only tested device. GM2 stays explicitly untested; carry the warning forward verbatim. - No re-engineering of the unofficial API (no new endpoints, no scraping, no caching layer beyond the existing token cache).
- No GUI/TUI, no daemon, no server mode. It stays a one-shot CLI.
- No telemetry / analytics.
- No breaking changes to the
--jsonschemas in v1 (per §2). - Not a public Go library. All non-entrypoint code lives under
internal/so we can refactor freely without API-compatibility obligations.
| Area | Decision | Rationale |
|---|---|---|
| Language / version | Go, go 1.24 directive; CI builds with stable |
Recent-but-not-bleeding-edge; go directive is a hard minimum, newer toolchains auto-resolve |
| Module path | github.com/stozo04/speediance-cli |
Replaces the Python repo in place; same import/install path |
| Binary name | speediance-cli (entry cmd/speediance-cli/main.go) |
True drop-in; existing docs/skill say speediance-cli |
| CLI framework | spf13/cobra |
Standard for Go CLIs (gh, kubectl); clean subcommands, help, completions |
| Config | stdlib encoding/json + os.LookupEnv + Cobra flags (no Viper) |
A 6-command CLI doesn't need Viper's transitive weight; full control of precedence |
| HTTP retries | hashicorp/go-retryablehttp (GET/idempotent only) |
Sane default policy, honors Retry-After; exposes a plain *http.Client |
| Fuzzy match | hbollon/go-edlib + stdlib Sørensen-Dice token overlap |
Actively maintained, normalized [0,1] ratio; "good-enough" parity for sync |
| JSON | stdlib encoding/json (Encoder, HTML-escape off) |
Streaming, clean machine output |
| Logging | stdlib log/slog → stderr |
No need for zap/zerolog at CLI scale |
| Tests | stdlib testing + net/http/httptest + google/go-cmp; testify/require only for guards |
Idiomatic, great diffs, minimal deps |
| Lint/format | golangci-lint v2 (default: standard + a few extras) + gofumpt/goimports |
Single meta-linter bundles vet/staticcheck/errcheck |
| Release | GoReleaser v2 + GitHub Actions on v* tags |
Cross-platform binaries, checksums, changelog |
| Install paths | release binaries and go install …/cmd/speediance-cli@latest and ClawHub skill |
All three distribution channels requested |
Deliberately small dependency set: spf13/cobra, hashicorp/go-retryablehttp, hbollon/go-edlib
(+ test-only google/go-cmp, optional testify). No Viper, no third-party logging.
Thin cmd/ entrypoint + closed internal/ tree (the gh pattern, minus pkg/ since we expose no
library). Do not adopt golang-standards/project-layout.
speediance-cli/
go.mod # module github.com/stozo04/speediance-cli ; go 1.24
go.sum
LICENSE # MIT (carry over)
README.md AGENTS.md SKILL.md # rewritten for the Go tool (see §15)
library.json # carry over the committed GM1 catalog snapshot
.goreleaser.yaml
.golangci.yml
.gitignore # config.json, .token.json, .env, dist/, plans/
.github/workflows/
ci.yml # build + test -race + lint on push/PR
release.yml # goreleaser on v* tags
publish-clawhub.yml # carry over; trigger on SKILL.md change
cmd/speediance-cli/
main.go # thin: NewRootCmd().ExecuteContext(); map error→exit code; os.Exit
internal/
cli/ # cobra wiring — one file per command
root.go # NewRootCmd(), App struct, PersistentPreRunE (load config)
login.go workouts.go session.go library.go push.go sync.go
config.go version.go # new commands
exit.go # ExitError{Code,Err} type
output.go # writeJSON helper, stdout/stderr discipline
api/
client.go # *http.Client, headers, auth, token refresh (code 91), do/doJSON
endpoints.go # workouts/session/library/template calls
types.go # request + response structs with JSON tags
config/
config.go # defaults → file → env → flags; discovery; validation
auth/
tokencache.go # read/write .token.json at 0600
template/
template.go # build payload (KG_TO_API, unilateral, capacity), create
workout/
model.go # Workout/SetData equivalents, record parsing, ts normalization
sheet/
sheet.go # WEEKS/Week-XX.md writer
match.go # fuzzy matching (go-edlib + token overlap)
version/
version.go # ldflags vars + debug.ReadBuildInfo fallback
testdata/ # golden files, sample WEEKS sheet, recorded API JSON, sample plans
cmd/speediance-cli/main.go(vs a rootmain.go) givesgo installa clean binary name and room for a second tool later (e.g.cmd/gen-docs). Cobra commands live ininternal/cli, notcmd/.
| Command | Status | --json |
Notes |
|---|---|---|---|
login |
port | — | authenticate, cache token |
workouts [--days N] |
port | yes | list recent completed sessions (each row carries kind) |
session <training_id> [--free|--program] |
port | yes | full detail for one session; auto-detects program/free/rowing |
today [--date D] |
new | yes | every session on a day, auto-resolved to type-correct detail |
library [--search X] [--out FILE] |
port | yes | dump/search exercise catalog |
push <plan.json> [--dry-run] |
port | yes | create program (dry-run previews payload) |
sync [--date D] [--days N] [--weeks-dir DIR] |
port | — | optional Markdown sheet write |
config [show|set|path] |
new | yes (show) |
manage config.json without hand-editing |
version |
new | yes | build metadata; plus root --version |
completion [bash|zsh|fish|powershell] |
new | — | Cobra built-in shell completions |
Global persistent flags on root: --config PATH, --verbose/-v. Each command keeps its existing
local flags. Use ExecuteContext(ctx) so a cancelable context reaches every command for HTTP
timeouts and Ctrl-C.
| Source | Keys |
|---|---|
| Env vars | SPEEDIANCE_EMAIL, SPEEDIANCE_PASSWORD, SPEEDIANCE_REGION, SPEEDIANCE_DEVICE_TYPE, SPEEDIANCE_WEEKS_DIR, SPEEDIANCE_CONFIG, SPEEDIANCE_TOKEN_CACHE |
config.json keys |
email, password, region, unit, device_type, weeks_dir |
| Defaults | region="Global", unit="lb" (label only), device_type=1, weeks_dir="" |
flags > env > config file > defaults. (Python only did env > file; flags-win is the new, idiomatic
layer. A flag overrides only when actually set — check cmd.Flags().Changed(...); env uses
two-return os.LookupEnv to distinguish set-empty from unset.)
--config PATH(new flag) orSPEEDIANCE_CONFIGenv → explicit path.- else
config.jsonin the current working directory (preserves today's behavior and the skill docs). - (enhancement) else
<os.UserConfigDir>/speediance/config.json— documented as optional, never required. Keep CWD as the default so existing agents/skills are unaffected.
Token cache: SPEEDIANCE_TOKEN_CACHE env > token_cache_path config key >
<os.UserCacheDir>/speediance/token.json per-user default. (The original Python/Go default
of .token.json in CWD was changed in issue #17: a CWD default leaked a live token into
whatever repo the tool ran from, where a git add -A could commit it. An existing legacy
.token.json is migrated to the per-user location on first run so no one is forced to re-login.
The base is os.UserCacheDir — the non-roaming cache dir (%LocalAppData%), not the roaming
os.UserConfigDir (%AppData%) — so a live credential isn't synced across machines; see
.claude/CLI_CONVENTIONS.md §1. Config files still resolve under os.UserConfigDir.)
- Missing
emailorpasswordafter resolution → friendly error to stderr + non-zero exit (preserve Python's message intent: tell the user to set env vars orconfig.json). syncwithout aweeks_dir(flag/env/config) → hard error explaining onlysyncneeds it.device_typeparsed as int; carry the GM2-untested warning.
- Write with
os.OpenFile(path, O_CREATE|O_WRONLY|O_TRUNC, 0o600)(NOTos.WriteFile, which won't re-restrict perms on an existing file).MkdirAllparent0700if using a non-CWD location. - On Windows,
0600is best-effort — must not fail the command if chmod is unsupported (mirror Python'stry/except). - Contents unchanged:
{"token": "...", "user_id": "..."}. After every successful API call the refreshed token/user_id is written back (as Python does).
Security note for the maintainer:
config.jsonstores the password in plaintext today and is gitignored. Keep it gitignored. Prefer env vars for agents/headless use. An OS-keychain backend is a possible future enhancement, out of scope for v1.
internal/api/client.go. Build a custom *http.Client (never http.DefaultClient) with an
explicit timeout (~20–30s like Python), wrapped by go-retryablehttp for transient retries on
idempotent GETs only.
Global → https://api2.speediance.com/api
EU → https://euapi.speediance.com/api
Unknown region falls back to Global (as Python does). Host header derived from the base URL host.
Host: <derived from base>
User-Agent: Dart/3.9 (dart:io)
Content-Type: application/json
Timestamp: <current epoch milliseconds, as string>
Utc_offset: +0000
Timezone: GMT
Versioncode: 40304
Accept-Language: en
App_type: SOFTWARE
Mobiledevices: {"brand":"google","device":"emulator64","deviceType":"sdk_gphone64","os":"","os_version":"31","manufacturer":"Google"}
Token: <token> # only when present
App_user_id: <user_id> # only when present
User-Agent here is the spoofed Dart string — do not replace it with speediance-cli/<ver>.
(Our own version string belongs only in --version/logs.)
POST /app/v2/login/verifyIdentitybody{"type":2,"userIdentity":<email>}.code != 0→AuthError(verifyIdentity failed: <message>).data.isExist == false→AuthError("Account does not exist…").data.hasPwd == false→AuthError("Account has no password set…")(Google/SSO accounts).
POST /app/v2/login/byPassbody{"userIdentity":<email>,"password":<pw>,"type":2}.code != 0→AuthError(Login failed: <message>).- on success:
token = data.token,user_id = str(data.appUserId).
Every GET/POST: if response code == 91, re-login() once, then retry the same request once.
This is an application-level retry distinct from transport retries; it applies to POSTs too
(safe — a 91 means the request was rejected unprocessed). Lazy login: if no token is set when a
request is made, log in first.
go-retryablehttp: retry on connection errors and 5xx; honorRetry-Afteron 429;RetryMax ≈ 3, exponential backoff.- Restrict transport retries to safe methods (GET/HEAD). Do not auto-retry the
loginorpush/customTrainingTemplatePOSTs at the transport layer — only the deliberate code-91 single retry — to avoid duplicate logins or duplicate program creation. - Set the retry client's logger to a slog adapter on stderr (or nil) so retries never pollute stdout.
defer resp.Body.Close(), check status, cap reads withio.LimitReader.- Decode the standard envelope
{ "code": int, "message": string, "data": <any> }. Usejson.RawMessagefordatawhere the shape depends on the endpoint, and decode loosely — do not useDisallowUnknownFieldson API responses (resilience to new server fields).
For each: behavior, flags, exact stdout JSON shape (the contract), stderr, exit. Use a shared
writeJSON (encoder, SetEscapeHTML(false), SetIndent("", " "), trailing newline) so output
matches Python's json.dumps(indent=2).
- Authenticate (no token cache used), then write
.token.json. - stdout (human):
Logged in (user <id>). Token cached to <path> — keep this file private. - stderr: the "do not share this file" note.
- No
--json. Auth failure → exit 2.
fetch_workouts(days):start = (today+1d) - N days,end = today+1d; GET/mobile/v2/report/userTrainingDataRecord?startDate=<ISO>&endDate=<ISO>.code != 0→ empty list (+ stderr warning). Write back token cache.- stdout
--json— array of:{"training_id": 0, "title": "", "date": "YYYY-MM-DD|null", "duration_secs": 0, "calories": 0, "volume": 0.0, "type": ""} - stdout (human):
Found N session(s)…then- <date> <title> - <min> min, <kcal> kcal (id <id>); empty →No completed workouts in the last N day(s).
Autonomous, faithful, lossless passthrough (issue #23, refined). A session is a
dumb, complete dump of the Speediance data — the CLI must not interpret, summarize,
reshape, rename, compute, or fabricate. But a session may live in either of two
namespaces, and the tool resolves which itself so a caller never needs to know
the type. This supersedes the earlier typed/derived design (a --telemetry flag,
reps_detail reshape, snake_case renames, synthesized weight/weight_source),
all removed.
- Two namespaces, auto-dispatched. Program/Coach detail: GET
cttTrainingInfo/<id>+cttTrainingInfoDetail/<id>. Free-lift detail (freestyle weights, rowing/ski): GETfreeTraining/<id>+freeTrainingDetail/<id>.ResolveSessionprobes program first, falls back to free, and reports which answered viakind. AtrainingIdcollides across namespaces (the same number is unrelated sessions in each), so--program/--freeforce one namespace, andtoday(which has the authoritative numerictypefrom the list) dispatches collision-free. - Uniform shape so a type-agnostic agent reads the same fields regardless:
{"training_id": 0, "kind": "program", "info": { /* verbatim cttTrainingInfo data (program) OR freeTraining data (free) */ }, "detail": [ /* verbatim cttTrainingInfoDetail (program) OR freeTrainingDetail (free, usually []) */ ]}kind∈ {"program","free",""}; it reflects which namespace answered (a routing fact, not data interpretation).info/detailare the endpoints'datapayloads verbatim; a missing one is JSONnull(absence preserved, never fabricated). No derived fields — the real per-rep weights live intrainingInfoDetail.weights[]; consumers average if they want a single number. todayis the one-shot date entry point: it lists the day's records (--date today|yesterday|YYYY-MM-DD), reads each one's numerictype(collision- free), and returns an array of the same{training_id, kind, info, detail}.workoutsrows gainkind(mapped from the numerictype: 5→program, 1→free, else"") for filtering.- Flags policy: only scope/selection flags —
<id>,--date,--free,--program,--config. No flag may reshape, derive, summarize, or filter a record's content;--free/--programonly select the namespace.
fetch_library(device_type):- GET
/app/actionLibraryTab/list?deviceType=<dt>→ tabs; skip tabs whereisCustomtruthy. - per tab: GET
/app/actionLibraryGroup/trainingPartGroup?tabId=<id>&deviceTypeList=<dt>; collect each action{id, name:title, muscle:"", tab:tabName}, dedupe by id. - enrich muscle: for ids in chunks of 50, GET
/app/actionLibraryGroup/list?ids=…&ids=…→ setmuscle = mainMuscleGroupName.
- GET
- Always writes the full catalog to
--out(stderr:Saved N exercises to <file>). --searchfilters by case-insensitive substring in name or muscle.- stdout
--json— array of{"id":0,"name":"","muscle":"","tab":""}(filtered if--search). - Human +
--search:N match '<q>':then[id] name (muscle)(first 60). Human without search: nothing on stdout (only the stderr save line).
load_plan: JSON must containnameandexercises(else error). Plan shape:{"name":"Pull Day","exercises":[ {"id":434,"title":"…","sets":[{"reps":12,"weight":20,"mode":1,"rest":75}]}]}- stderr summary:
Plan: <name> - <nEx> exercises, <nSets> sets. --dry-run: build payload, do not POST.--jsonprints the payload (see §10); human prints[dry-run] totalCapacity=…; not sent.and per-actiongroupId … reps … | weights ….- real:
POST /app/v2/customTrainingTemplate;code != 0→ error.--jsonprints responsedata; human printsCreated '<name>' on your Speediance account….
See §11. Requires a weeks dir; no --json.
config show [--json]— print resolved effective config with password masked (****).--jsonemits a stable object (omit/blank the password). This is a convenience view; it is not a Python-parity surface.config set <key> <value>— write/updateconfig.json(the discovered/--configpath), creating it if absent; restrict perms to0600since it holds a secret.config path— print the resolved config + token-cache paths to stdout.
version [--json]prints{ "version", "commit", "date", "go" }; root--versionprints the short version string. Values via ldflags withdebug.ReadBuildInfo()fallback (see §14).
internal/template. This is the highest-risk parity surface: the produced JSON is the API wire
body and the weight math determines real loads on a physical machine.
KG_TO_API = 2.2(kg → internal API units).group_ids= unique intids across plan exercises.- Variant id:
GET /app/actionLibraryGroup/list?ids=…→ for eachd,actionLibraryId = d.actionLibraryList[0].id. Unresolved id → error naming the id/title and telling the user to runlibrary. - Unilateral: per group,
GET /app/actionLibraryGroup/<id>?isDisplay=1→data.isLeftRight == 1.
reps_list[i] = str(reps);break_list[i] = str(rest)(default rest 60);mode_list[i] = str(mode)(default 1);level_list[i] = "0".leftRight[i] = ("1" if i%2==0 else "2")when unilateral, else"0".completionMethod[i] = "1",countType[i] = "1",selectCompletionMethod[i] = "1".api_weight = weight * 2.2;weights[i] = format(api_weight, 1 decimal)→ e.g.44.0(Go:strconv.FormatFloat(api_weight, 'f', 1, 64)).set_capacity += reps * api_weight.
{"groupId": <int>, "actionLibraryId": <int>, "templatePresetId": -1,
"setsAndReps": "12,10", "breakTime": "75,75", "breakTime2": "75,75",
"sportMode": "1,1", "leftRight": "0,0", "selectCompletionMethod": "1,1",
"completionMethod": "1,1", "countType": "1,1", "weights": "44.0,49.5",
"counterweight2": "", "level": "0,0", "capacity": <float>}Note breakTime2 duplicates breakTime; counterweight2 is "".
{"name": <string>, "actionLibraryList": [ …actions… ],
"totalCapacity": <sum of set_capacity>, "deviceType": <int>, "bgColor": 0}Test obligation: golden-file the payload for plans/example-push.json and plans/week-01-legs.json
and assert byte-equality with the Python output (e.g. weights == "44.0,49.5" for kg 20, 22.5).
1=Standard, 2=Eccentric, 3=Isokinetic, 4=Constant, 5=Spotter. rest seconds. weight kilograms.
Optional integration that writes a completed session into a Markdown WEEKS/Week-XX.md checklist.
Behavior to preserve; matching may be "good enough" (not byte-identical to Python's difflib).
- Resolve
weeks_dir(flag → env → config) else hard error. - Resolve date:
today|yesterday|YYYY-MM-DD. fetch_workouts(max(days,1)); keep those whose.date == target(local-time interpretation — see §12 edge). None → message + return.find_week_sheet: globWeek-*.mdsorted; pick the file containing a^##.*\b<m/d>\bheader, else the highest-numbered file. None → message.- For each session:
fetch_detail, thenwrite_session.
- Date token:
"<month>/<day>"with no leading zeros (e.g.6/15). - Checkboxes:
☐empty /☑done (exact Unicode). - Locate the day's workout section (
##line containing the token, not "Notes"). - Inside it, for each 4-column table row whose first cell is a checkbox: fuzzy-match the exercise cell
against workout exercise names; on a match, set the box to
☑and fill the weight cell with a compact"<w><unit>×<reps>, …"string. Each workout name used at most once. - Flip the at-a-glance row's trailing checkbox for the date.
- Build a full "Logged from Speediance — <title> (<m/d>) - - [- % complete]"
block listing every exercise (captures unmatched too); insert under the matching Notes bullet, else
append a
## Logged Sessionssection. - Return
{sheet, matched, unmatched, exercise_count}; CLI prints matched/total and any unmatched.
Python used 0.5*SequenceMatcher.ratio() + 0.5*token_overlap, threshold 0.45, after normalization
(lowercase, strip parentheticals, dumbbell→db, barbell→bb, synonyms rdl→romanian deadlift,
ohp→overhead press, stopword removal). Reproduce the normalization, and compute similarity as a
weighted blend using hbollon/go-edlib (e.g. Jaro-Winkler or Levenshtein ratio) + a stdlib
Sørensen-Dice over token sets:
score = 0.5*edlib.StringsSimilarity(a,b,algo) + 0.5*sorensenDice(tokens(a),tokens(b))
match if score >= threshold (start at 0.45, tune on fixtures)
Tune weights/threshold/algo against the real tests/sample_week.md + make_workout() fixtures so the
existing test's expectations (≥6 boxes flipped, weights written, glance checked) still pass. Document
that exact byte-parity with Python is not a goal here. If perfect parity were ever required, the
fallback is to vendor pmezard/go-difflib's Ratio() (a real Ratcliff-Obershelp port, but
unmaintained) into internal/sheet.
- Wrap errors with
%wat boundaries that add context; use sentinel errors (var ErrAuth = …) for parameterless conditions and typed errors where structured data (status code) matters; match witherrors.Is/errors.As. - Single exit point.
main()callsrun() intwhich executes Cobra withExecuteContext; map errors to codes via a typedExitError{Code int; Err error}checked witherrors.As. Noos.Exitinside command bodies (it skips deferred cleanup). - Set root
SilenceUsage = trueandSilenceErrors = true; print the single error line to stderr yourself (keeps stdout clean and avoids dumping usage on runtime failures).
| Condition | Exit |
|---|---|
| Success | 0 |
Auth error (AuthError) |
2 (preserve Python) |
Missing credentials / missing --weeks-dir / bad plan / unresolved exercise id |
non-zero (1, or a dedicated code) |
| Any other error | 1 |
Edge — timestamp/date: a record's date comes from start_timestamp || end_timestamp; if the value
is > 1e12 treat it as milliseconds (/1000), else seconds; convert with local time (Python
datetime.fromtimestamp) — this determines which calendar day a session maps to. Decode the raw
number via json.Number/UseNumber() to avoid float lossiness, then normalize in a typed accessor.
- stdout: machine output only — JSON under
--json, or the specific human strings the Python commands print (those are the de-facto human UI; keep them on stdout to match behavior). Use thewriteJSONencoder with HTML escaping off. - stderr: all hints, warnings, progress, and
log/slogdiagnostics. Default level Warn;--verbose→ Debug; optionalLOG_LEVELenv override. Text handler for humans; a--log-format=json(stderr) is a nice-to-have, not required. - Commands must write via
cmd.OutOrStdout()/cmd.ErrOrStderr()(notos.Stdoutdirectly) so tests can capture output.
Expose build metadata robustly whether built by GoReleaser or go install:
- ldflags
-X main.version=… -X main.commit=… -X main.date=…(set by GoReleaser). - Fallback to
runtime/debug.ReadBuildInfo()forbi.Main.Versionandvcs.revision/time/modified(populated bygo install pkg@vXandgo buildin a repo). - Gotcha:
-X main.versiononly sets a var inpackage main. Either declare the ldflags vars incmd/speediance-cli/main.go(package main) and pass them intointernal/version, or point ldflags at the full path…/internal/version.version. Pick one and document it in.goreleaser.yaml.
Rewrite the three docs so they describe the Go tool — drop the python3 requirement everywhere:
- README.md — install via release binary /
go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latest; same command examples; keep the GM1/GM2 + unofficial-API + credits notes. - AGENTS.md — same command surface and plan schema; replace pip/clone-Python setup with binary
download /
go install; keep the stdout-parseable + secrets-gitignored conventions. - SKILL.md — update
requires.binsfrompython3to none (bundled binary) or document thego install/download step; keepenvVars, permissions, and emoji. The auto-publish Action stays. - Carry over
library.json,LICENSE(MIT),config.example.json,.env.example, and theplans/examples (still gitignoreplans/).
Target behavioral parity, verified offline (no network), mirroring and extending the two Python tests.
- Table-driven tests with subtests;
t.TempDir(),t.Setenv(). (Go 1.22+ needs no loop-var capture.) - API client via
net/http/httptest: stand up a server returning recorded Speediance JSON fixtures (store undertestdata/), point the client base URL atserver.URL; assert the client parses workouts/session/library correctly and handlescode == 91re-login (server returns 91 once, then 0) and region selection. - Template payload golden tests: build payloads for
plans/example-push.jsonandplans/week-01-legs.json; assert byte-equality with checked-in golden JSON (the frozen wire format), includingweights == "44.0,49.5", alternatingleftRightfor a unilateral fixture, andtotalCapacity. - Sheet/sync: port
tests/test_sheet.py— copysample_week.mdto a temp file, write a synthetic session, assert ≥6 boxes flipped, weights present, "Logged from Speediance" block, glance row checked, no blank weight cells before## Notes. - Fuzzy matcher: unit-test normalization + scoring on the known sheet-vs-Speediance name pairs; assert the intended matches clear the threshold and bad pairs don't.
- Config precedence: defaults < file < env < flags, including unset-vs-empty.
- Cobra commands:
cmd.SetArgs, captureSetOut/SetErrbuffers, assert stdout JSON shape and that hints went to stderr; fresh command per test (no sticky global flags). - Use stdlib
testing+google/go-cmpfor struct/JSON diffs;testify/requireonly for terse nil/err guards. Rungo test -race ./...in CI.
- Formatting (enforced):
gofumpt+goimports(local-prefixgithub.com/stozo04/speediance-cli); CI fails if formatting drifts. - golangci-lint v2,
.golangci.ymlwithlinters.default: standardplusrevive, gosec, misspell, bodyclose, errorlint, nilnil(start conservative, notall).bodycloseguards the HTTP client. Don't separately run staticcheck (bundled);go vet ./...is fine as a free extra. ci.yml(push/PR):actions/setup-go@v6withgo-version: stable→go build ./...→go test -race ./...; separatelintjob withgolangci/golangci-lint-action.- go.mod:
go 1.24, omit atoolchaindirective.
builds:main: ./cmd/speediance-cli,binary: speediance-cli,CGO_ENABLED=0,goos: [linux, darwin, windows],goarch: [amd64, arm64],ldflags: -s -w -X main.version=… -X main.commit=… -X main.date=…,mod_timestamp.archivestar.gz (zip override for Windows);checksumssha256;changelog: use: github.- (Later, optional: Homebrew tap, cosign signing, SBOM.)
permissions: contents: write; checkout withfetch-depth: 0;setup-gostable;goreleaser-action@v7withargs: release --clean,GITHUB_TOKENfrom secrets.
- Module path = repo path;
go install github.com/stozo04/speediance-cli/cmd/speediance-cli@latestresolves the highest semver tag. Tag releases asvMAJOR.MINOR.PATCH. (These builds lack ldflags vars → version falls back toReadBuildInfo.)
- Keep
publish-clawhub.yml(trigger onSKILL.mdchange). UpdateSKILL.mdso the skill no longer requirespython3— it downloads the release binary or usesgo install. Verify the skill installs and runs end-to-end without a Python interpreter.
- P0 — Scaffold.
go.mod(github.com/stozo04/speediance-cli,go 1.24), layout from §5,cmd/speediance-cli/main.goshim +internal/cli/root.go,versioncmd,.golangci.yml,ci.yml.go build/go test/lint green on an empty skeleton. - P1 — Config + auth + client.
internal/config,internal/auth,internal/api(headers, regions, two-step login, code-91 refresh, retry policy).logincommand works against fixtures; token cache0600. Unit tests via httptest. - P2 — Read commands.
workouts,sessionwith exact--jsonschemas + human output + stdout/stderr discipline. Tests assert schema parity. - P3 — Library.
library(tab walk, dedupe, 50-id muscle chunks,--out,--search,--json). - P4 — Push.
internal/templatepayload (KG×2.2, unilateral, capacity, frozen field names),--dry-run, real create. Golden-file byte-parity tests against the sample plans. - P5 — Sync.
internal/sheetwriter + fuzzy matcher; port the sheet test; tune threshold. - P6 — Modern extras.
configcommand,completion, richer errors, HTTP retries,--version. - P7 — Test/parity pass. Recorded-fixture comparison of all
--jsonoutputs vs the Python tool;go test -racegreen; lint clean. - P8 — Docs. README / AGENTS / SKILL rewritten (no
python3); examples verified. - P9 — Release.
.goreleaser.yaml+release.yml; cutv1.0.0; verify binaries for 3 OSes,go install …@latest, and ClawHub skill install. - P10 — Repo cutover. Land the Go tree on a branch in
stozo04/speediance-cli, PR-review, remove Python sources, merge, tag. (See §21.)
- Given identical API responses (recorded fixtures), every
--jsoncommand emits JSON whose field names, nesting, and types match the Python tool exactly. push --dry-run --jsonfor the sample plans is byte-identical to the Python payload.- Exit codes match (auth → 2, success → 0, errors non-zero); stdout carries only machine output, stderr carries hints/logs.
- Existing env vars and
config.json/.token.jsonwork unchanged (a user with a cached token isn't forced to re-login). go buildcross-compiles for linux/darwin/windows × amd64/arm64;go test -race ./...passes;golangci-lintis clean.- Tool runs with no Python interpreter present; ClawHub skill installs and works without
python3. - New commands (
config,version,completion) work and are documented.
Because the Go tool replaces github.com/stozo04/speediance-cli in place:
- Develop in this folder (
speediance-cli-go) until P9 is green. - On a branch in the real repo: add the Go tree,
go.mod, workflows; rewrite docs. - Remove the Python package (
speediance/,pyproject.toml,requirements.txt, Python tests) in the same PR somainis never half-and-half. PreserveLICENSE,library.json,config.example.json,.env.example,plans/examples, and the (updated)SKILL.md+ ClawHub Action. - Keep
mainPR-protected (existing convention). Merge, then tagv1.0.0to trigger the release. - Note in the README/release that v1.0.0 is the Go rewrite and is CLI-compatible with the Python
versions (same commands, same
--json).
- Unofficial API drift. If Speediance updates the app, headers/endpoints may break;
internal/apiis the single patch point (mirror the Python "all calls live in client.py" convention). - Fuzzy-match tuning. Weights/threshold/algorithm need empirical tuning on real sheets; flagged as "good enough," not byte-parity.
- Windows file perms.
0600is best-effort on Windows — must not fail commands. go installversion strings. Will beReadBuildInfo-derived, not ldflags — acceptable; document.- GM2 still untested — carry the warning, do not imply support.
- Open choice (low stakes): whether to also support the
os.UserConfigDirconfig location in v1 or defer it. Default plan: keep CWDconfig.jsonas the only default; add XDG later if wanted.
Best-practice sources this spec relies on (verified current 2025/2026):
- Project layout — go.dev/doc/modules/layout · golang-standards/project-layout#117 · cli/cli
- Cobra — cobra.dev/docs · pkg.go.dev/github.com/spf13/cobra · spf13/cobra#2124 (exit codes)
- Config/paths — pkg.go.dev/os#UserConfigDir · #UserCacheDir · #WriteFile · #LookupEnv
- HTTP — "Don't use Go's default http client" (nate510) · pkg.go.dev/net/http#Client · hashicorp/go-retryablehttp
- Errors — go.dev/blog/go1.13-errors · pkg.go.dev/errors
- Testing — go.dev/blog/subtests · pkg.go.dev/net/http/httptest · pkg.go.dev/github.com/google/go-cmp/cmp · go.dev/doc/go1.22
- JSON — pkg.go.dev/encoding/json · go.dev/doc/go1.24 (omitzero)
- Logging — go.dev/blog/slog
- Release — goreleaser.com (v2 builds/ci) · go.dev/ref/mod#go-install · pkg.go.dev/runtime/debug#ReadBuildInfo
- Lint/CI — golangci-lint.run (v2 config + migration) · golangci/golangci-lint-action · go.dev/doc/toolchain
- Fuzzy match — github.com/hbollon/go-edlib · pkg.go.dev/github.com/pmezard/go-difflib/difflib
Spec authored against the Python speediance-cli source (speediance/*.py, v0.1.0) on 2026-06-16.