@olegkoval/agent-skills 1.22.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 (38) hide show
  1. package/.claude-plugin/plugin.json +4 -2
  2. package/.cursor-plugin/index.json +10 -0
  3. package/.grok-plugin/index.json +10 -0
  4. package/.kiro/steering/garmin-watchface.md +7 -0
  5. package/.windsurf/rules/garmin-watchface.md +7 -0
  6. package/README.md +3 -1
  7. package/catalog/skills.json +44 -0
  8. package/package.json +1 -1
  9. package/packages/software-development/ci-fix-loop/SKILL.md +202 -0
  10. package/packages/software-development/ci-fix-loop/adapters/claude/plugin.json +5 -0
  11. package/packages/software-development/ci-fix-loop/adapters/claude/skills/ci-fix-loop/SKILL.md +203 -0
  12. package/packages/software-development/ci-fix-loop/adapters/codex/README.md +20 -0
  13. package/packages/software-development/ci-fix-loop/adapters/cursor/plugin.json +6 -0
  14. package/packages/software-development/ci-fix-loop/adapters/cursor/skills/ci-fix-loop/SKILL.md +203 -0
  15. package/packages/software-development/ci-fix-loop/adapters/grok/plugin.json +6 -0
  16. package/packages/software-development/ci-fix-loop/adapters/grok/skills/ci-fix-loop/SKILL.md +203 -0
  17. package/packages/software-development/pr-description-writer/SKILL.md +152 -0
  18. package/packages/software-development/pr-description-writer/adapters/claude/plugin.json +5 -0
  19. package/packages/software-development/pr-description-writer/adapters/claude/skills/pr-description-writer/SKILL.md +153 -0
  20. package/packages/software-development/pr-description-writer/adapters/codex/README.md +19 -0
  21. package/packages/software-development/pr-description-writer/adapters/cursor/plugin.json +6 -0
  22. package/packages/software-development/pr-description-writer/adapters/cursor/skills/pr-description-writer/SKILL.md +153 -0
  23. package/packages/software-development/pr-description-writer/adapters/grok/plugin.json +6 -0
  24. package/packages/software-development/pr-description-writer/adapters/grok/skills/pr-description-writer/SKILL.md +153 -0
  25. package/packages/wearables/garmin-watchface/SKILL.md +7 -0
  26. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/SKILL.md +7 -0
  27. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/references/publishing.md +145 -0
  28. package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/scripts/ciq-release +333 -0
  29. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/SKILL.md +7 -0
  30. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/references/publishing.md +145 -0
  31. package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/scripts/ciq-release +333 -0
  32. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/SKILL.md +7 -0
  33. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/references/publishing.md +145 -0
  34. package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/scripts/ciq-release +333 -0
  35. package/packages/wearables/garmin-watchface/adapters/kiro/steering/garmin-watchface.md +7 -0
  36. package/packages/wearables/garmin-watchface/adapters/windsurf/rules/garmin-watchface.md +7 -0
  37. package/packages/wearables/garmin-watchface/references/publishing.md +145 -0
  38. package/packages/wearables/garmin-watchface/scripts/ciq-release +333 -0
@@ -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())