diff --git a/SKILL.md b/SKILL.md index af49d90..225048c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,11 +18,13 @@ metadata: - "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)" + - "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: @@ -201,7 +203,10 @@ A program session: Notes for consumers: - **Auto-detection is built in** — no caller knowledge of the session type is - needed. `session`/`today` probe the program namespace, then the free namespace. + 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 @@ -221,7 +226,8 @@ Notes for consumers: - **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 `kind:"free"`, and `null` only for `kind:""`. + 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`. @@ -237,6 +243,8 @@ speediance-cli library # save full catalog to library. ``` 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. diff --git a/internal/config/skill_doc_test.go b/internal/config/skill_doc_test.go index 6d674c7..8f0ebd2 100644 --- a/internal/config/skill_doc_test.go +++ b/internal/config/skill_doc_test.go @@ -45,6 +45,55 @@ func TestSkillDocAdvertisesEveryEnvVar(t *testing.T) { } } +// TestSkillDocAdvertisesFileAccess extends the §0 (advertised == actual) guard +// to the SKILL.md permissions block (CLAWHUB_STANDARDS §4): every file the +// binary actually reads or writes must appear in the corresponding files.read / +// files.write list, so the published skill never under-declares a capability. +// It asserts the negative — a real file access MISSING from the block fails +// loudly — the exact drift that previously left the `config set` write of +// config.json and the working-directory .env read unadvertised. +func TestSkillDocAdvertisesFileAccess(t *testing.T) { + skill, err := os.ReadFile(filepath.Join(repoRoot(t), "SKILL.md")) + if err != nil { + t.Fatalf("read SKILL.md: %v", err) + } + doc := string(skill) + + // Slice one permissions list out of the frontmatter by its start key and the + // key that follows it (order: network, files.read, files.write, requires). + block := func(start, end string) string { + t.Helper() + i := strings.Index(doc, start) + if i < 0 { + t.Fatalf("SKILL.md permissions block missing %q", start) + } + rest := doc[i+len(start):] + j := strings.Index(rest, end) + if j < 0 { + t.Fatalf("SKILL.md: no %q after %q — permissions block reordered?", end, start) + } + return rest[:j] + } + reads := block("files.read:", "files.write:") + writes := block("files.write:", "requires:") + + // Everything the binary reads: config.json (internal/config discovery), the + // token cache (auth.Load), plan JSON (template.LoadPlan), and the + // working-directory .env (godotenv.Read in internal/config). + for _, want := range []string{"config.json", "token", "plan", ".env"} { + if !strings.Contains(reads, want) { + t.Errorf("SKILL.md files.read does not mention %q, which the code reads — advertised != actual (CLAWHUB_STANDARDS §4)", want) + } + } + // Everything the binary writes: the token cache (auth.Save), library.json + // (library --out via writeJSONFile), and config.json (config set → setConfigKey). + for _, want := range []string{"token", "library.json", "config.json"} { + if !strings.Contains(writes, want) { + t.Errorf("SKILL.md files.write does not mention %q, which the code writes — advertised != actual (CLAWHUB_STANDARDS §4)", want) + } + } +} + // TestSkillDocPromisesNoShellOut backs the same §0 cornerstone (and // CLAWHUB_STANDARDS §4/§5): the skill advertises `requires.bins: []` — a single // static binary that never shells out — so no production Go source may import