@olegkoval/agent-skills 1.23.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.kiro/steering/garmin-watchface.md +7 -0
  3. package/.windsurf/rules/garmin-watchface.md +7 -0
  4. package/package.json +1 -1
  5. package/packages/wearables/garmin-watchface/SKILL.md +7 -0
  6. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/SKILL.md +7 -0
  7. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/references/publishing.md +145 -0
  8. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/scripts/ciq-release +333 -0
  9. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/SKILL.md +7 -0
  10. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/references/publishing.md +145 -0
  11. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/scripts/ciq-release +333 -0
  12. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/SKILL.md +7 -0
  13. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/references/publishing.md +145 -0
  14. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/scripts/ciq-release +333 -0
  15. package/packages/wearables/garmin-watchface/adapters/kiro/steering/garmin-watchface.md +7 -0
  16. package/packages/wearables/garmin-watchface/adapters/windsurf/rules/garmin-watchface.md +7 -0
  17. package/packages/wearables/garmin-watchface/references/publishing.md +145 -0
  18. package/packages/wearables/garmin-watchface/scripts/ciq-release +333 -0
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-agent-skills",
3
3
  "description": "Agent-agnostic skill catalog for Codex, Claude, Cursor, Grok, Copilot, Windsurf, Kiro, and other skill-aware tools.",
4
- "version": "1.22.0",
4
+ "version": "1.23.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -22,6 +22,7 @@ looked like success first.
22
22
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
23
23
  | `references/devices.md` | Adding device support, launcher icons, API levels |
24
24
  | `references/store.md` | Publishing, listing copy, IP questions |
25
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
25
26
 
26
27
  ## Tools
27
28
 
@@ -34,8 +35,14 @@ Run these rather than reinventing them. All are standalone.
34
35
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
35
36
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
36
37
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
38
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
37
39
  ```
38
40
 
41
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
42
+ portal. Every check in it is something otherwise discovered halfway through the
43
+ submission form -- a stale screenshot set, an icon still copied from the last
44
+ project, or copy containing a character the description validator rejects.
45
+
39
46
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
40
47
  a window: without a frontmost check it silently photographs whatever is on top,
41
48
  and the window moves between simulator restarts. Both failure modes produce a
@@ -21,6 +21,7 @@ looked like success first.
21
21
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
22
22
  | `references/devices.md` | Adding device support, launcher icons, API levels |
23
23
  | `references/store.md` | Publishing, listing copy, IP questions |
24
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
24
25
 
25
26
  ## Tools
26
27
 
@@ -33,8 +34,14 @@ Run these rather than reinventing them. All are standalone.
33
34
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
34
35
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
35
36
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
37
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
36
38
  ```
37
39
 
40
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
41
+ portal. Every check in it is something otherwise discovered halfway through the
42
+ submission form -- a stale screenshot set, an icon still copied from the last
43
+ project, or copy containing a character the description validator rejects.
44
+
38
45
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
39
46
  a window: without a frontmost check it silently photographs whatever is on top,
40
47
  and the window moves between simulator restarts. Both failure modes produce a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olegkoval/agent-skills",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -25,6 +25,7 @@ looked like success first.
25
25
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
26
26
  | `references/devices.md` | Adding device support, launcher icons, API levels |
27
27
  | `references/store.md` | Publishing, listing copy, IP questions |
28
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
28
29
 
29
30
  ## Tools
30
31
 
@@ -37,8 +38,14 @@ Run these rather than reinventing them. All are standalone.
37
38
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
38
39
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
39
40
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
41
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
40
42
  ```
41
43
 
44
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
45
+ portal. Every check in it is something otherwise discovered halfway through the
46
+ submission form -- a stale screenshot set, an icon still copied from the last
47
+ project, or copy containing a character the description validator rejects.
48
+
42
49
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
43
50
  a window: without a frontmost check it silently photographs whatever is on top,
44
51
  and the window moves between simulator restarts. Both failure modes produce a
@@ -26,6 +26,7 @@ looked like success first.
26
26
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
27
27
  | `references/devices.md` | Adding device support, launcher icons, API levels |
28
28
  | `references/store.md` | Publishing, listing copy, IP questions |
29
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
29
30
 
30
31
  ## Tools
31
32
 
@@ -38,8 +39,14 @@ Run these rather than reinventing them. All are standalone.
38
39
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
39
40
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
40
41
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
42
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
41
43
  ```
42
44
 
45
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
46
+ portal. Every check in it is something otherwise discovered halfway through the
47
+ submission form -- a stale screenshot set, an icon still copied from the last
48
+ project, or copy containing a character the description validator rejects.
49
+
43
50
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
44
51
  a window: without a frontmost check it silently photographs whatever is on top,
45
52
  and the window moves between simulator restarts. Both failure modes produce a
@@ -0,0 +1,145 @@
1
+ # Publishing through the browser
2
+
3
+ `references/store.md` covers *what* the listing needs. This covers *driving the
4
+ portal*, which is the only way in — Garmin publishes no submission API.
5
+
6
+ Run `scripts/ciq-release` before opening a browser. Everything it checks is
7
+ something you would otherwise discover halfway through the form.
8
+
9
+ ## The portal is automatable
10
+
11
+ The obvious conclusion is that it is not: `apps.garmin.com/developer/dashboard`
12
+ sits behind Garmin SSO *and* Cloudflare bot protection, and a headless browser
13
+ lands on a "Performing security verification" interstitial. That is what you see
14
+ if you navigate cold.
15
+
16
+ It works anyway, because the session is already sitting in your own browser.
17
+ Import the cookies:
18
+
19
+ ```bash
20
+ B=~/.claude/skills/gstack/browse/dist/browse
21
+
22
+ $B cookie-import-browser comet --domain .garmin.com # note the LEADING DOT
23
+ $B goto https://apps.garmin.com/ # host-scoped cookies
24
+ $B cookie-import-browser comet --domain apps.garmin.com # need you to be on
25
+ $B goto https://sso.garmin.com/ # the host first
26
+ $B cookie-import-browser comet --domain sso.garmin.com
27
+
28
+ $B goto https://apps.garmin.com/developer/dashboard # no SSO redirect
29
+ ```
30
+
31
+ Three separate traps in that block:
32
+
33
+ - **`garmin.com` imports nothing. `.garmin.com` imports everything.** The cookie
34
+ is stored under the dotted domain and the filter is an exact match, so the
35
+ obvious spelling silently returns `Imported 0 cookies` and looks like "not
36
+ logged in".
37
+ - **Host-scoped cookies need you on that host first**, or the import refuses
38
+ with *"does not match current page domain"*. The `.garmin.com` set alone is
39
+ NOT enough to authenticate — the session only holds once
40
+ `apps.` and `sso.` are imported too.
41
+ - **Substitute your browser.** `comet`, `chrome`, `brave`, `arc`, `edge`,
42
+ `chromium` are supported. Whichever one you actually signed into.
43
+
44
+ Verify by checking the URL after `goto`: an unauthenticated session is bounced
45
+ to `sso.garmin.com/portal/sso/...`, an authenticated one stays put.
46
+
47
+ ## "The Connect IQ store is currently in maintenance mode"
48
+
49
+ This string is in the DOM of the dashboard and of every app page, next to
50
+ *Upload New Version*, *Edit Details* and *Remove*. Reading the page text makes
51
+ it look like every action is blocked.
52
+
53
+ **It is pre-rendered hidden markup, not an active block.** Click the button and
54
+ the real form loads. Do not report the store as down on the strength of a text
55
+ dump — click first, then check for a *visible* dialog:
56
+
57
+ ```bash
58
+ $B js "(function(){var o=[];document.querySelectorAll('[role=dialog],.modal').forEach(function(x){if(x.offsetParent!==null)o.push(x.innerText.slice(0,120))});return o.join('|')||'no visible modal'})()"
59
+ ```
60
+
61
+ ## The two-step update flow
62
+
63
+ *Upload New Version* → `/apps/<uuid>/update`, which is two steps.
64
+
65
+ **Step 1 — attach.** A file input and an *App Version* textbox. Publish stays
66
+ disabled until both are set; the page shows the current version so you can pick
67
+ the next one.
68
+
69
+ ```bash
70
+ $B upload "input[type=file]" "$(pwd)/bin/<name>.iq"
71
+ $B fill "input[id*='version'],input[name*='version']" "2.0"
72
+ $B click "button:has-text('Upload and publish')"
73
+ ```
74
+
75
+ On success the page reports **Status: Verified, Signature: Verified** and
76
+ expands the product list — a manifest naming one product can resolve to several
77
+ store devices (`fenix6pro` becomes fēnix 6 Pro, 6 Pro Dual Power, 6 Pro Solar
78
+ and quatix 6).
79
+
80
+ **Step 2 — details.** Title, description, What's New, images, category, contact
81
+ email. Ends in *Submit*.
82
+
83
+ The description arrives pre-filled with the copy from the PREVIOUS version.
84
+ After a redesign it describes a face that no longer exists, and nothing prompts
85
+ you about it. Re-paste from `docs/store/listing.md` every time.
86
+
87
+ ## Image slots, in DOM order
88
+
89
+ There are seven `input[type=file]` elements and none of them carry a name, id
90
+ or label. Order is the only way to tell them apart, so confirm it before
91
+ uploading rather than trusting this table:
92
+
93
+ ```bash
94
+ $B js "(function(){var f=document.querySelectorAll('input[type=file]');var o=[];f.forEach(function(x,i){var n=x,c='';for(var k=0;k<8&&n;k++){n=n.parentElement;if(n&&n.innerText.trim().length>25){c=n.innerText.replace(/\s+/g,' ').slice(0,60);break}}o.push(i+' >> '+c)});return o.join('\n')})()"
95
+ ```
96
+
97
+ | # | Slot | Size | Limit |
98
+ | --- | --- | --- | --- |
99
+ | 0 | Hero image | **1440×720** | 2048 KB |
100
+ | 1 | Cover image | **500×500** | 300 KB |
101
+ | 2–6 | Screen images | any | **150 KB each** |
102
+
103
+ Getting the order wrong puts a 260px screenshot where the hero belongs, and the
104
+ form accepts it.
105
+
106
+ ## The description validator
107
+
108
+ Two rejections, both discovered only after filling the entire form and pressing
109
+ Submit:
110
+
111
+ - `Illegal characters found: <, >.` — an ASCII `->` arrow is enough to trip it.
112
+ - `The app description is invalid. It should not contain emojis.` — **U+2699
113
+ GEAR counts**, which is exactly the character you reach for when writing
114
+ "tap the gear icon". U+2192 RIGHTWARDS ARROW passes today but lives in the
115
+ same symbol space.
116
+
117
+ Write settings paths in words: *"Garmin Connect app, then: your device, Connect
118
+ IQ Apps, Watch Faces, the app name, settings"*. `scripts/ciq-release` checks the fenced
119
+ blocks of `listing.md` for both classes before you open a browser.
120
+
121
+ The error text renders as a leaf node with no `aria-invalid` anywhere, so find
122
+ it by scanning for childless visible elements rather than by form state:
123
+
124
+ ```bash
125
+ $B js "(function(){var o=[];document.querySelectorAll('*').forEach(function(x){if(!x.children.length&&x.offsetParent!==null&&/invalid|illegal/i.test(x.innerText))o.push(x.innerText.trim())});return Array.from(new Set(o)).join(' || ')})()"
126
+ ```
127
+
128
+ Success is a redirect to `apps.garmin.com/apps/<uuid>`. Staying on `/update`
129
+ means a validation failure.
130
+
131
+ ## Things not to click
132
+
133
+ **Remove** sits between *Edit Details* and *Download* in the same button row.
134
+ Never target buttons by index in that row.
135
+
136
+ **Upload and publish** and **Submit** are outward-facing: they push to a public
137
+ listing and accept the Developer License Agreement on the account holder's
138
+ behalf. Confirm with the human before either, even when they have asked for the
139
+ release — version number and stale copy are both worth one question.
140
+
141
+ ## Account state
142
+
143
+ A developer account pending identity verification can still upload; approval
144
+ gates public visibility, not submission. A newly submitted app sits in preview
145
+ mode, visible only to its owner, for up to three days.
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ """Pre-submission check for a Connect IQ project.
3
+
4
+ Everything that can be verified WITHOUT logging into the store, verified here,
5
+ so the browser session is spent on the form rather than on discovering that the
6
+ package is stale.
7
+
8
+ ciq-release # check ./ and print the verdict
9
+ ciq-release path/to/proj
10
+ ciq-release --quiet # exit code only
11
+
12
+ Exit 0 when submittable, 1 when something is wrong.
13
+
14
+ The checks are the ones that have actually gone wrong, not a generic lint:
15
+
16
+ * screenshots older than the package they claim to show. store.md calls stale
17
+ screenshots the most common thing to forget after a redesign, and they
18
+ misrepresent the version under review -- which is a review rejection, not a
19
+ cosmetic slip.
20
+ * a launcher icon that is byte-identical to another project's. Copying a
21
+ scaffold and forgetting the icon ships someone else's mark.
22
+ * signing keys inside the project. The key signs your app id: commit it and
23
+ anyone can impersonate the app; lose it and you can never update the
24
+ listing.
25
+ * a package older than the sources it was built from.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import re
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ RESET = "\033[0m"
37
+ RED = "\033[31m"
38
+ GREEN = "\033[32m"
39
+ YELLOW = "\033[33m"
40
+
41
+
42
+ class Report:
43
+ def __init__(self, quiet: bool) -> None:
44
+ self.quiet = quiet
45
+ self.failed = 0
46
+ self.warned = 0
47
+
48
+ def _line(self, colour: str, tag: str, msg: str, detail: str) -> None:
49
+ if self.quiet:
50
+ return
51
+ pad = f" {colour}{tag}{RESET} {msg}"
52
+ print(pad if not detail else f"{pad}\n {detail}")
53
+
54
+ def ok(self, msg: str, detail: str = "") -> None:
55
+ self._line(GREEN, "ok ", msg, detail)
56
+
57
+ def warn(self, msg: str, detail: str = "") -> None:
58
+ self.warned += 1
59
+ self._line(YELLOW, "warn", msg, detail)
60
+
61
+ def fail(self, msg: str, detail: str = "") -> None:
62
+ self.failed += 1
63
+ self._line(RED, "FAIL", msg, detail)
64
+
65
+
66
+ def newest(paths) -> float:
67
+ stamps = [p.stat().st_mtime for p in paths if p.is_file()]
68
+ return max(stamps) if stamps else 0.0
69
+
70
+
71
+ def check_manifest(root: Path, r: Report) -> None:
72
+ manifest = root / "manifest.xml"
73
+ if not manifest.is_file():
74
+ r.fail("no manifest.xml", f"looked in {root}")
75
+ return
76
+ text = manifest.read_text(encoding="utf-8", errors="replace")
77
+
78
+ app_id = re.search(r'id="([0-9a-fA-F]{32})"', text)
79
+ if not app_id:
80
+ r.fail("manifest has no 32-hex application id")
81
+ else:
82
+ r.ok(f"app id {app_id.group(1)}")
83
+
84
+ products = re.findall(r'<iq:product\s+id="([^"]+)"', text)
85
+ if not products:
86
+ r.fail("manifest declares no products")
87
+ else:
88
+ r.ok(f"{len(products)} product(s): {', '.join(products)}")
89
+
90
+ # A watch face that forgot its permissions still builds, then reads null
91
+ # from every sensor on the watch and nowhere else.
92
+ perms = re.findall(r'uses-permission\s+id="([^"]+)"', text)
93
+ r.ok(f"permissions: {', '.join(perms) if perms else 'none'}")
94
+
95
+
96
+ def check_package(root: Path, r: Report) -> Path | None:
97
+ packages = list((root / "bin").glob("*.iq")) if (root / "bin").is_dir() else []
98
+ if not packages:
99
+ r.fail("no .iq package", "run: make package")
100
+ return None
101
+ iq = max(packages, key=lambda p: p.stat().st_mtime)
102
+ built = iq.stat().st_mtime
103
+
104
+ sources = list((root / "source").rglob("*.mc"))
105
+ sources += list((root / "resources").rglob("*"))
106
+ if newest(sources) > built:
107
+ r.fail(
108
+ f"{iq.name} is older than the sources it was built from",
109
+ "run: make package",
110
+ )
111
+ else:
112
+ r.ok(f"{iq.name} ({iq.stat().st_size // 1024} KB) is current")
113
+ return iq
114
+
115
+
116
+ def check_screenshots(root: Path, _iq: Path | None, r: Report) -> None:
117
+ store = root / "docs" / "store"
118
+ shots = sorted(store.glob("*.png")) if store.is_dir() else []
119
+ if not shots:
120
+ r.warn("no screenshots in docs/store/", "the listing form needs at least one")
121
+ return
122
+
123
+ # Screenshots are checked against their source files (source/*.mc and
124
+ # resources/*) rather than the package they claim to show. The question is
125
+ # "do these images show the current face?", and only a source or resource
126
+ # change can make them stale. Stale screenshots misrepresent the version
127
+ # under review, which is a review rejection, not a cosmetic slip.
128
+ sources = list((root / "source").rglob("*.mc"))
129
+ sources += [p for p in (root / "resources").rglob("*") if p.is_file()]
130
+ changed = newest(sources)
131
+ newest_shot = newest(shots)
132
+ if changed > newest_shot:
133
+ stale = [p.name for p in sources if p.stat().st_mtime > newest_shot]
134
+ r.fail(
135
+ f"{len(shots)} screenshot(s) predate the current sources",
136
+ "changed since capture: " + ", ".join(sorted(stale)[:4])
137
+ + ("..." if len(stale) > 4 else ""),
138
+ )
139
+ else:
140
+ r.ok(f"{len(shots)} screenshot(s), current with the sources")
141
+
142
+
143
+ def check_icon(root: Path, r: Report) -> None:
144
+ icons = list((root / "resources").rglob("launcher_icon.png"))
145
+ if not icons:
146
+ r.warn("no launcher_icon.png found")
147
+ return
148
+
149
+ digest = hashlib.sha256(icons[0].read_bytes()).hexdigest()
150
+
151
+ # Compare against every sibling project. A scaffold copied from the last
152
+ # face keeps its icon, and nothing in the build complains.
153
+ for sibling in root.parent.iterdir():
154
+ if not sibling.is_dir() or sibling.resolve() == root.resolve():
155
+ continue
156
+ for other in sibling.rglob("launcher_icon.png"):
157
+ if hashlib.sha256(other.read_bytes()).hexdigest() == digest:
158
+ r.fail(
159
+ "launcher icon is byte-identical to another project's",
160
+ f"same file as {other}",
161
+ )
162
+ return
163
+ r.ok("launcher icon is unique to this project")
164
+
165
+
166
+ def check_keys(root: Path, r: Report) -> None:
167
+ candidates = [p for p in root.rglob("*") if p.suffix in (".der", ".pem") and p.is_file()]
168
+
169
+ # Inspect file contents to confirm they contain private key material
170
+ private_keys = []
171
+ for p in candidates:
172
+ if p.suffix == ".pem":
173
+ try:
174
+ content = p.read_text(errors="replace")
175
+ # Check for private key PEM headers
176
+ if any(marker in content for marker in [
177
+ "BEGIN PRIVATE KEY",
178
+ "BEGIN RSA PRIVATE KEY",
179
+ "BEGIN EC PRIVATE KEY",
180
+ "BEGIN ENCRYPTED PRIVATE KEY",
181
+ ]):
182
+ private_keys.append(p)
183
+ except Exception:
184
+ pass
185
+ elif p.suffix == ".der":
186
+ # DER files are binary; use a simple heuristic: assume .der files
187
+ # in typical key locations are keys unless they're obviously certs
188
+ # (this is a best-effort check; DER format inspection is complex)
189
+ try:
190
+ data = p.read_bytes()
191
+ # Very basic heuristic: if file is small and doesn't contain
192
+ # obvious certificate markers, treat as potential private key
193
+ # This is imperfect but better than blanket rejection
194
+ if len(data) < 10000: # Private keys are typically smaller than cert bundles
195
+ private_keys.append(p)
196
+ except Exception:
197
+ pass
198
+
199
+ if private_keys:
200
+ r.fail(
201
+ "signing key material inside the project",
202
+ "\n ".join(str(p) for p in private_keys),
203
+ )
204
+ return
205
+
206
+ gitignore = root / ".gitignore"
207
+ ignored = gitignore.read_text(errors="replace") if gitignore.is_file() else ""
208
+ if "*.der" not in ignored or "*.pem" not in ignored:
209
+ r.warn(".gitignore does not cover *.der / *.pem")
210
+ else:
211
+ r.ok("no keys in the tree, and both extensions are ignored")
212
+
213
+
214
+ def check_tests(root: Path, r: Report) -> None:
215
+ log = root / "bin" / "test.log"
216
+ if not log.is_file():
217
+ r.warn("no bin/test.log", "run: make test")
218
+ return
219
+ text = log.read_text(errors="replace")
220
+ if re.search(r"^PASSED \(passed=\d+, failed=0, errors=0\)", text, re.M):
221
+ count = re.search(r"passed=(\d+)", text)
222
+ r.ok(f"{count.group(1) if count else '?'} tests passed")
223
+ else:
224
+ r.fail("bin/test.log does not end in a clean PASSED line")
225
+
226
+
227
+ def check_listing(root: Path, r: Report) -> None:
228
+ listing = root / "docs" / "store" / "listing.md"
229
+ if not listing.is_file():
230
+ r.warn("no docs/store/listing.md", "the form wants copy ready to paste")
231
+ return
232
+ doc = listing.read_text(errors="replace")
233
+
234
+ # Only the FENCED blocks are checked -- those are what gets pasted into the
235
+ # store form. The surrounding prose is notes for a human and legitimately
236
+ # contains things the store would reject, like an autolinked <https://...>
237
+ # portal URL. Scanning the whole file flags the documentation.
238
+ raw = "\n".join(re.findall(r"^```\n(.*?)^```", doc, re.S | re.M))
239
+ if not raw:
240
+ r.warn("listing.md has no fenced copy blocks to check")
241
+ return
242
+
243
+ # The store's description validator rejects two things, and both are
244
+ # discovered the slow way -- by filling the whole form and being bounced:
245
+ #
246
+ # "Illegal characters found: <, >."
247
+ # "The app description is invalid. It should not contain emojis."
248
+ #
249
+ # A GEAR (U+2699) counts as an emoji, which is easy to reach for when
250
+ # writing "tap the gear icon". Arrows (U+2192) pass today, but they sit in
251
+ # the same symbol space and cost nothing to avoid.
252
+ if "<" in raw or ">" in raw:
253
+ r.fail(
254
+ "listing copy contains < or >",
255
+ "the store rejects them: \"Illegal characters found\"",
256
+ )
257
+ else:
258
+ r.ok("no angle brackets in the copy")
259
+
260
+ # Explicit emoji Unicode ranges (common blocks that Garmin store rejects)
261
+ def is_emoji(c: str) -> bool:
262
+ o = ord(c)
263
+ return (
264
+ (0x1F300 <= o <= 0x1F9FF) or # Miscellaneous Symbols and Pictographs, Emoticons, etc.
265
+ (0x2600 <= o <= 0x27BF) or # Miscellaneous Symbols, Dingbats
266
+ (0x2300 <= o <= 0x23FF) or # Miscellaneous Technical (includes gear U+2699)
267
+ (0x2190 <= o <= 0x21FF) or # Arrows (includes U+2192)
268
+ (0x2900 <= o <= 0x297F) or # Supplemental Arrows-B
269
+ (0x2B00 <= o <= 0x2BFF) or # Miscellaneous Symbols and Arrows
270
+ (0x3030 <= o <= 0x303F) or # CJK Symbols and Punctuation (wavy dash, etc.)
271
+ (0xFE00 <= o <= 0xFE0F) # Variation Selectors
272
+ )
273
+ emoji = sorted({c for c in raw if is_emoji(c)})
274
+ if emoji:
275
+ r.fail(
276
+ "listing copy contains symbols the store reads as emoji",
277
+ " ".join(f"{c!r} (U+{ord(c):04X})" for c in emoji),
278
+ )
279
+ else:
280
+ r.ok("no emoji in the copy")
281
+
282
+ text = doc.lower()
283
+ # Watch face settings are unreachable from the watch. Omitting this reads as
284
+ # a missing feature to every reviewer and every user.
285
+ if "garmin connect" not in text:
286
+ r.warn(
287
+ "listing never mentions the Garmin Connect app",
288
+ "watch face settings are not reachable from the watch; say so or "
289
+ "it reads as a missing feature",
290
+ )
291
+ else:
292
+ r.ok("listing explains where settings live")
293
+
294
+
295
+ def main() -> int:
296
+ ap = argparse.ArgumentParser(add_help=True)
297
+ ap.add_argument("project", nargs="?", default=".")
298
+ ap.add_argument("--quiet", action="store_true")
299
+ args = ap.parse_args()
300
+
301
+ root = Path(args.project).resolve()
302
+ r = Report(args.quiet)
303
+
304
+ if not args.quiet:
305
+ print(f"Connect IQ release check\n {root}\n")
306
+
307
+ check_manifest(root, r)
308
+ iq = check_package(root, r)
309
+ check_screenshots(root, iq, r)
310
+ check_icon(root, r)
311
+ check_keys(root, r)
312
+ check_tests(root, r)
313
+ check_listing(root, r)
314
+
315
+ if not args.quiet:
316
+ print()
317
+ if r.failed:
318
+ print(f" {RED}{r.failed} blocking problem(s){RESET}, {r.warned} warning(s)")
319
+ elif r.warned:
320
+ print(f" {YELLOW}submittable, {r.warned} warning(s){RESET}")
321
+ else:
322
+ print(f" {GREEN}submittable{RESET}")
323
+ if not r.failed:
324
+ print(
325
+ "\n Upload: apps.garmin.com/developer/dashboard\n"
326
+ " The portal IS drivable headlessly once the session cookies are\n"
327
+ " imported from a signed-in browser. See references/publishing.md."
328
+ )
329
+ return 1 if r.failed else 0
330
+
331
+
332
+ if __name__ == "__main__":
333
+ sys.exit(main())
@@ -26,6 +26,7 @@ looked like success first.
26
26
  | `references/simulator.md` | Screenshots, settings not applying, monkeydo hanging |
27
27
  | `references/devices.md` | Adding device support, launcher icons, API levels |
28
28
  | `references/store.md` | Publishing, listing copy, IP questions |
29
+ | `references/publishing.md` | Driving the store portal in a browser; upload/update flow, validator rejections |
29
30
 
30
31
  ## Tools
31
32
 
@@ -38,8 +39,14 @@ Run these rather than reinventing them. All are standalone.
38
39
  <skill-dir>/scripts/ciq-capture out.png # calibrated simulator screenshot
39
40
  <skill-dir>/scripts/ciq-capture out.png --face --size 260 # cropped + masked to the round display
40
41
  <skill-dir>/scripts/ciq-calibrate # re-derive the display rect if capture looks wrong
42
+ <skill-dir>/scripts/ciq-release # pre-submission check: package, screenshots, icon, keys, copy
41
43
  ```
42
44
 
45
+ `<skill-dir>/scripts/ciq-release` is what you run before opening the store
46
+ portal. Every check in it is something otherwise discovered halfway through the
47
+ submission form -- a stale screenshot set, an icon still copied from the last
48
+ project, or copy containing a character the description validator rejects.
49
+
43
50
  `<skill-dir>/scripts/ciq-capture` exists because `screencapture -R` grabs a screen *region*, not
44
51
  a window: without a frontmost check it silently photographs whatever is on top,
45
52
  and the window moves between simulator restarts. Both failure modes produce a