@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.
- package/.claude-plugin/plugin.json +4 -2
- package/.cursor-plugin/index.json +10 -0
- package/.grok-plugin/index.json +10 -0
- package/.kiro/steering/garmin-watchface.md +7 -0
- package/.windsurf/rules/garmin-watchface.md +7 -0
- package/README.md +3 -1
- package/catalog/skills.json +44 -0
- package/package.json +1 -1
- package/packages/software-development/ci-fix-loop/SKILL.md +202 -0
- package/packages/software-development/ci-fix-loop/adapters/claude/plugin.json +5 -0
- package/packages/software-development/ci-fix-loop/adapters/claude/skills/ci-fix-loop/SKILL.md +203 -0
- package/packages/software-development/ci-fix-loop/adapters/codex/README.md +20 -0
- package/packages/software-development/ci-fix-loop/adapters/cursor/plugin.json +6 -0
- package/packages/software-development/ci-fix-loop/adapters/cursor/skills/ci-fix-loop/SKILL.md +203 -0
- package/packages/software-development/ci-fix-loop/adapters/grok/plugin.json +6 -0
- package/packages/software-development/ci-fix-loop/adapters/grok/skills/ci-fix-loop/SKILL.md +203 -0
- package/packages/software-development/pr-description-writer/SKILL.md +152 -0
- package/packages/software-development/pr-description-writer/adapters/claude/plugin.json +5 -0
- package/packages/software-development/pr-description-writer/adapters/claude/skills/pr-description-writer/SKILL.md +153 -0
- package/packages/software-development/pr-description-writer/adapters/codex/README.md +19 -0
- package/packages/software-development/pr-description-writer/adapters/cursor/plugin.json +6 -0
- package/packages/software-development/pr-description-writer/adapters/cursor/skills/pr-description-writer/SKILL.md +153 -0
- package/packages/software-development/pr-description-writer/adapters/grok/plugin.json +6 -0
- package/packages/software-development/pr-description-writer/adapters/grok/skills/pr-description-writer/SKILL.md +153 -0
- package/packages/wearables/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/adapters/claude/skills/garmin-watchface/scripts/ciq-release +333 -0
- package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/adapters/cursor/skills/garmin-watchface/scripts/ciq-release +333 -0
- package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/SKILL.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/references/publishing.md +145 -0
- package/packages/wearables/garmin-watchface/adapters/grok/skills/garmin-watchface/scripts/ciq-release +333 -0
- package/packages/wearables/garmin-watchface/adapters/kiro/steering/garmin-watchface.md +7 -0
- package/packages/wearables/garmin-watchface/adapters/windsurf/rules/garmin-watchface.md +7 -0
- package/packages/wearables/garmin-watchface/references/publishing.md +145 -0
- 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())
|