CLI + HTTP server for syncing Apple Health export data into a local SQLite database. Parses .zip or .xml exports, stores in ~/.healthsync/healthsync.db.
cmd/ — Cobra CLI commands (parse, query, server, skills, version)
skills/ — embedded skill files (go:embed source)
healthsync/ — skill prompt + version file for agents
skills_embed.go — package cmd; //go:embed skills/healthsync
internal/
parser/ — Streaming XML parser with DTD stripping, zip support
storage/ — SQLite schema, batch inserts, query helpers
server/ — Chi HTTP server with async upload
skills/ — generic Install/Uninstall/DetectAgents logic
scripts/ — install.sh (served at healthsync.sidv.dev/install)
website/ — Hugo site (custom theme, no npm)
static/install — copy of scripts/install.sh, served at /install
make build # outputs bin/healthsync
make test # run all tests
make release # darwin/linux tar.gz + windows zip into bin/
go test ./... -coverprofile=coverage.out && go tool cover -func=coverage.outinternal/parser— ~90% (~30 tests; expanded for 40+ metric types, blood pressure, category types)internal/storage— ~88% (~55 tests; dedup, daily totals, all new tables)internal/server— 73.7% (17 tests)internal/skills— 8 tests (Install/Uninstall round-trip, idempotent reinstall, DetectAgents, etc.)cmd/— 13 cases (formatCommas, QueryTotalSupportedTables)- Total: ~86 tests; ~88-90% on core packages
- DTD must be stripped via
io.Pipegoroutine (notbufio.Scanner+MultiReader— scanner consumes too many bytes) - Must NOT call
decoder.Skip()on<HealthData>root element — it skips all children - Category types (sleep, mindful_sessions, stand_hours) are
HKCategoryType(no unit attribute) —RecordColumns()returns 4 columns, not 5; value stored as TEXT - Parser uses per-table
map[string][][]anybatch buffers — each table flushes at 1000 rows; all flush at EOF - Blood pressure staging: systolic + diastolic keyed by
{sourceName, startDate}; row emitted only when both are staged; unpaired records silently dropped - Zip parsing is filename-agnostic:
findHealthExportsniffs the first 32 KiB of each.xmlentry for<HealthData(trailing space guards against<HealthDataArchive). Handles localized filenames (e.g.导出.xmlon Chinese-locale devices) without hardcoded locale lists. CDA sibling (export_cda.xml) is rejected naturally because its root is<ClinicalDocument>. - Timestamps are normalized at parse time:
normalizeTimestampstrips the trailing±HHMMoffset so stored values are plainYYYY-MM-DD HH:MM:SSlocal time. Applied to all date fields (Record start/end, Workout start/end, blood pressure staging key). SQLite date funcs (julianday,date) cannot parse the space-separated offset format, so this is required for--totalaggregations to work.
- WAL mode enabled for concurrent reads during server mode
INSERT OR IGNOREwith UNIQUE constraints for dedup- Batch size: 1000 rows per transaction
- DB path:
~/.healthsync/healthsync.db(override with--db) - Schema variants: 5-col standard, 4-col no-unit (category types), 6-col blood_pressure, 10-col workouts
TableNameMapmaps both hyphen and underscore CLI names to DB table names (60+ entries)--format table|json|csv— CSV/JSON usesortedKeys()for deterministic column order--totalroutes to dedicated daily-total methods (NOTQueryRows) with overlap dedup. Supported:steps,active-energy,basal-energy,sleepsleep --totalusesdate(start_date, '-6 hours')to group by sleep night (pre-midnight sessions attributed to the correct night); filtersvalue LIKE '%Asleep%'to exclude InBed and Awake;--tofilter also gates on the shifted night date, not rawstart_date- Source-priority deduplication: Watch=2 > iPhone=1 > other=0 (uses
strings.Containson sourceName)
POST /api/uploadreturns202 Accepted, parses async in goroutineGET /api/upload/statusfor polling progress (usessync/atomiccounters)- Returns
409 Conflictif a parse is already running
github.com/spf13/cobra— CLIgithub.com/go-chi/chi/v5— HTTP routermodernc.org/sqlite— pure Go SQLite (no CGO)github.com/jedib0t/go-pretty/v6— table output (query command)github.com/charmbracelet/huh— interactive prompts (skills install agent picker)github.com/fatih/color— terminal color output
scripts/install.sh— curl installer, supports macOS and Linux (arm64 + amd64)- Copied verbatim to
website/static/install(Hugo serves it at/install, no extension) - Install:
curl -fsSL https://healthsync.sidv.dev/install | bash - Pattern: pre-flight OS/arch check → resolve latest GitHub release → download tar.gz → extract → install to
/usr/local/bin(sudo if needed) - No agent skill prompt in install script itself — skills are installed via
healthsync skills install
healthsync skills install/uninstall/status— installs agent skill prompt into~/.claude/skills/healthsync/(claude) or~/.agents/skills/healthsync/(codex)--agent claude|codex|allflag; interactivehuh.MultiSelectwhen TTY; auto-detect when non-TTY- Skills files embedded via
//go:embed skills/healthsyncincmd/skills_embed.go - go:embed path constraint: paths are relative to source file, no
..— skills must live undercmd/(not root) because root ispackage main - Version tracking via
.healthsync-versionfile inside each installed skill dir - Tests use
testing/fstest.MapFSas fake embedded FS (no real files needed)
- v0.5.1 (unreleased, on main) — strip tz offsets from stored timestamps,
sleep --totalwith 6h night shift, localized zip support (Chinese etc. via content sniffing) - v0.5.0 —
db infosubcommand, background update checker, OpenClaw skills target - v0.4.0 — 40+ Apple Health metrics, multi-format query output (table/json/csv), --total flag
- v0.3.0 — skills install/uninstall/status command; 6 platform archives
Steps in order — do not skip or reorder:
git push— push all commits to main first. Never tag unpushed commits.git tag vX.Y.Z— tag after push so the tag points to a commit already on remote maingit push origin vX.Y.Z— push the tag explicitlymake release— builds all 6 archives (bin/*.tar.gz bin/*.zip)gh release create vX.Y.Z bin/*.tar.gz bin/*.zip
- CRITICAL: Push before tag. Tagging an unpushed commit then running
gh release createpushes the tag + that commit but leavesmainbehind on remote — release binary is built from code not reachable from main.
- Conventional commits
- No mocks — tests use real temp SQLite databases
- Be proactive, not reactive — when given a task, just do it; don't ask for approval before starting