shortcuts-playground 1.2.1

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 (68) hide show
  1. package/README.md +54 -0
  2. package/agents/shortcut-builder.md +172 -0
  3. package/agents/shortcut-remixer.md +239 -0
  4. package/commands/build-shortcut.md +32 -0
  5. package/commands/remix-shortcut.md +34 -0
  6. package/opencode.json +30 -0
  7. package/package.json +47 -0
  8. package/plugin/validator.ts +179 -0
  9. package/skills/shortcuts-playground/ACTIONS.md +713 -0
  10. package/skills/shortcuts-playground/APPINTENTS.md +2760 -0
  11. package/skills/shortcuts-playground/AUTOMATION_TRIGGERS.md +133 -0
  12. package/skills/shortcuts-playground/BEST_PRACTICES.md +357 -0
  13. package/skills/shortcuts-playground/CHANGELOG.md +585 -0
  14. package/skills/shortcuts-playground/CONTROL_FLOW.md +777 -0
  15. package/skills/shortcuts-playground/DATE_TIME.md +75 -0
  16. package/skills/shortcuts-playground/EXAMPLES.md +738 -0
  17. package/skills/shortcuts-playground/FILTERS.md +697 -0
  18. package/skills/shortcuts-playground/HEALTHKIT.md +317 -0
  19. package/skills/shortcuts-playground/ICONS_AND_COLORS.md +89 -0
  20. package/skills/shortcuts-playground/JAVASCRIPT_WEBPAGE.md +51 -0
  21. package/skills/shortcuts-playground/PARAMETER_TYPES.md +1117 -0
  22. package/skills/shortcuts-playground/PLIST_FORMAT.md +296 -0
  23. package/skills/shortcuts-playground/README.md +77 -0
  24. package/skills/shortcuts-playground/SKILL.md +459 -0
  25. package/skills/shortcuts-playground/THIRD_PARTY_ACTIONS.md +77 -0
  26. package/skills/shortcuts-playground/TOOLKIT_SNAPSHOT.md +62 -0
  27. package/skills/shortcuts-playground/URL_SCHEMES.md +59 -0
  28. package/skills/shortcuts-playground/VARIABLES.md +569 -0
  29. package/skills/shortcuts-playground/assets/shortcuts-small.svg +14 -0
  30. package/skills/shortcuts-playground/assets/shortcuts.png +0 -0
  31. package/skills/shortcuts-playground/data/healthkit-ios26.2-reference.json +2603 -0
  32. package/skills/shortcuts-playground/data/macos27-shortpy-grounding.json +4085 -0
  33. package/skills/shortcuts-playground/data/macos27-workflow-trigger-samples.json +1 -0
  34. package/skills/shortcuts-playground/data/shortcuts-glyph-synonyms.json +5102 -0
  35. package/skills/shortcuts-playground/data/shortcuts-icon-colors.json +107 -0
  36. package/skills/shortcuts-playground/data/shortcuts-official-glyph-mapping.json +509 -0
  37. package/skills/shortcuts-playground/data/toolkit-v63-tool-ids.json +1806 -0
  38. package/skills/shortcuts-playground/data/toolkit-v78-first-party-enum-cases.json +1 -0
  39. package/skills/shortcuts-playground/data/toolkit-v78-first-party-parameter-keys.json +1 -0
  40. package/skills/shortcuts-playground/data/toolkit-v78-ios27-tool-ids.json +1222 -0
  41. package/skills/shortcuts-playground/data/toolkit-v78-tool-ids.json +2745 -0
  42. package/skills/shortcuts-playground/data/toolkit-v78-trigger-parameter-keys.json +1051 -0
  43. package/skills/shortcuts-playground/golden-shortcuts/index.jsonl +19 -0
  44. package/skills/shortcuts-playground/golden-shortcuts/xml/1be4dde95b794253bf82438e201b33e7.xml +174 -0
  45. package/skills/shortcuts-playground/golden-shortcuts/xml/2e0fb675e45948aaacee7e534f910492.xml +1016 -0
  46. package/skills/shortcuts-playground/golden-shortcuts/xml/332c12a0060043b388b22b806be7ab58.xml +1924 -0
  47. package/skills/shortcuts-playground/golden-shortcuts/xml/3dd4ee24e43f464f92adaa70a0311eaa.xml +1177 -0
  48. package/skills/shortcuts-playground/golden-shortcuts/xml/4d102301c6e646faa7a8a221f4f4ec98.xml +321 -0
  49. package/skills/shortcuts-playground/golden-shortcuts/xml/51cc4e26d1044893a0c3f2f3630cf2d2.xml +1933 -0
  50. package/skills/shortcuts-playground/golden-shortcuts/xml/623e7f1ca5f948e2bd53811fec63e544.xml +136 -0
  51. package/skills/shortcuts-playground/golden-shortcuts/xml/6a18b76843ac45c384ac3400f3740997.xml +1791 -0
  52. package/skills/shortcuts-playground/golden-shortcuts/xml/71f0cacb0f604b399b76c5dcb7286e7c.xml +2228 -0
  53. package/skills/shortcuts-playground/golden-shortcuts/xml/807525ed9f974829bc8494defac923a8.xml +259 -0
  54. package/skills/shortcuts-playground/golden-shortcuts/xml/8ab0d39826ab4249be58672763caa3ba.xml +420 -0
  55. package/skills/shortcuts-playground/golden-shortcuts/xml/91c45fd3fd3b427897d9ba485efb1227.xml +800 -0
  56. package/skills/shortcuts-playground/golden-shortcuts/xml/97be626bb25c41709d175646a7f6d8f2.xml +424 -0
  57. package/skills/shortcuts-playground/golden-shortcuts/xml/ae59e10d409348f9bd33894f03f9beb4.xml +568 -0
  58. package/skills/shortcuts-playground/golden-shortcuts/xml/afa83b6be811483b9c32189c41eb9312.xml +391 -0
  59. package/skills/shortcuts-playground/golden-shortcuts/xml/cfdb083b857e4ac189629fa386d27cdc.xml +193 -0
  60. package/skills/shortcuts-playground/golden-shortcuts/xml/e01cebe192d64b2fbca80204d03d92ab.xml +111 -0
  61. package/skills/shortcuts-playground/golden-shortcuts/xml/ef669bcf8bad489d9ef4b88bfaf5772f.xml +780 -0
  62. package/skills/shortcuts-playground/golden-shortcuts/xml/f44f5caf5e3e48d4817e73af450c4404.xml +570 -0
  63. package/skills/shortcuts-playground/scripts/generate_healthkit_reference.py +394 -0
  64. package/skills/shortcuts-playground/scripts/lookup_action_grounding.py +1164 -0
  65. package/skills/shortcuts-playground/scripts/select_shortcut_icon_color.py +597 -0
  66. package/skills/shortcuts-playground/scripts/test_random_mixed_shortcuts.py +1067 -0
  67. package/skills/shortcuts-playground/scripts/test_wiring_regressions.py +2247 -0
  68. package/skills/shortcuts-playground/scripts/validate_shortcut.py +4526 -0
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve Shortcuts icon glyph + color from natural language.
3
+
4
+ Examples:
5
+ python3 scripts/select_shortcut_icon_color.py --prompt "Build a weather shortcut"
6
+ python3 scripts/select_shortcut_icon_color.py --prompt "Expense tracker" --icon "calculator" --color gold
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import math
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ DEFAULT_GLYPH = 61440 # shortcuts
19
+ DEFAULT_COLOR_VALUE = 431817727 # Teal
20
+
21
+ STOP_WORDS = {
22
+ "a",
23
+ "an",
24
+ "and",
25
+ "app",
26
+ "as",
27
+ "be",
28
+ "build",
29
+ "by",
30
+ "create",
31
+ "for",
32
+ "from",
33
+ "i",
34
+ "icon",
35
+ "in",
36
+ "is",
37
+ "it",
38
+ "make",
39
+ "my",
40
+ "of",
41
+ "on",
42
+ "or",
43
+ "please",
44
+ "set",
45
+ "shortcut",
46
+ "symbol",
47
+ "that",
48
+ "the",
49
+ "this",
50
+ "to",
51
+ "use",
52
+ "with",
53
+ "workflow",
54
+ }
55
+
56
+ ICON_QUERY_PATTERNS = [
57
+ re.compile(
58
+ r"\b(?:use|set|choose|pick|with|make)\s+(?:a|an|the)?\s*([a-z0-9][a-z0-9 '&+./_-]{1,60}?)\s+(?:icon|glyph|symbol)\b",
59
+ re.I,
60
+ ),
61
+ re.compile(
62
+ r"\b(?:icon|glyph|symbol)\s*(?:to|as|is|named|name)?\s*([a-z0-9][a-z0-9 '&+./_-]{1,60})\b",
63
+ re.I,
64
+ ),
65
+ re.compile(r"\b(?:sf\s*symbol|sfsymbol|sf-symbol)\s*(?:to|as|is|named)?\s*([a-z0-9._+-]{1,60})\b", re.I),
66
+ ]
67
+
68
+ GLYPH_NUMBER_PATTERNS = [
69
+ re.compile(r"\b(?:glyph|icon|symbol)\s*(?:number|id|code)?\s*#?\s*(\d{5})\b", re.I),
70
+ re.compile(r"\b#(\d{5})\b"),
71
+ ]
72
+
73
+ HEX_COLOR_RE = re.compile(r"#([0-9a-fA-F]{6})\b")
74
+
75
+ COLOR_HINTS = {
76
+ "Red": {"alert", "urgent", "danger", "critical", "alarm", "warning", "error", "security"},
77
+ "Orange": {"warning", "caution", "attention"},
78
+ "Yellow": {"sun", "bright", "sunny", "daylight"},
79
+ "Gold": {"money", "finance", "budget", "expense", "invoice", "payment", "bank", "billing", "crypto"},
80
+ "Green": {"health", "fitness", "run", "workout", "exercise", "wellness", "eco", "habit"},
81
+ "Teal": {"automation", "shortcut", "workflow", "routine"},
82
+ "Cyan": {"weather", "cloud", "sky", "water"},
83
+ "Blue": {"travel", "map", "navigation", "flight", "internet", "network", "weather"},
84
+ "Navy": {"business", "work", "professional", "productivity", "office"},
85
+ "Purple": {"ai", "assistant", "creative", "music", "podcast", "magic", "design"},
86
+ "Lavender": {"journal", "mindfulness", "dream", "calm"},
87
+ "Pink": {"social", "chat", "message", "family", "love", "photo"},
88
+ "Gray": {"system", "settings", "utility", "tools", "developer", "terminal", "debug"},
89
+ "Sage": {"notes", "organize", "documents", "files", "archive"},
90
+ "Tan": {"book", "read", "writing", "manual"},
91
+ }
92
+
93
+ ICON_HINTS: dict[int, set[str]] = {
94
+ 59714: {"weather", "forecast", "cloud", "sky"},
95
+ 59715: {"rain", "storm", "precipitation", "weather"},
96
+ 59395: {"expense", "expenses", "finance", "budget", "money", "invoice", "billing", "payment"},
97
+ 59680: {"calculator", "calculate", "sum", "totals", "math"},
98
+ 59412: {"translate", "translation", "language", "languages", "dictionary", "global", "international"},
99
+ 61529: {"terminal", "shell", "bash", "zsh", "cli", "developer", "code", "script", "command"},
100
+ 59414: {"message", "messages", "chat", "conversation", "text", "sms"},
101
+ 59773: {"mail", "email", "inbox", "envelope"},
102
+ 59682: {"camera", "photo", "picture", "snapshot", "image"},
103
+ 61459: {"photos", "gallery", "album", "images"},
104
+ 59790: {"music", "song", "audio", "playlist"},
105
+ 59816: {"podcast", "podcasts", "episodes"},
106
+ 59681: {"calendar", "date", "schedule", "event", "events"},
107
+ 61587: {"task", "tasks", "todo", "reminder", "checklist"},
108
+ 61444: {"map", "maps", "navigation", "route", "directions", "location"},
109
+ 59648: {"flight", "airplane", "travel", "airport"},
110
+ 59743: {"settings", "preferences", "config", "configuration"},
111
+ 59749: {"tools", "tool", "utility", "maintenance"},
112
+ 59870: {"fix", "repair", "debug", "troubleshoot"},
113
+ 61566: {"ai", "assistant", "chatgpt", "llm", "robot", "agent"},
114
+ 61581: {"magic", "sparkle", "generate", "rewrite", "enhance"},
115
+ 59711: {"clipboard", "copy", "paste", "snippet"},
116
+ 59819: {"qr", "qrcode", "scan", "scanner", "otp"},
117
+ 59661: {"barcode", "sku", "inventory"},
118
+ 59754: {"health", "heart", "wellness"},
119
+ 59808: {"run", "running", "fitness", "workout", "exercise"},
120
+ 59867: {"wifi", "network", "internet", "wireless"},
121
+ 59489: {"battery", "power", "charge"},
122
+ 59770: {"lock", "secure", "password", "security"},
123
+ 62501: {"shield", "protection", "defense", "security"},
124
+ 59725: {"document", "documents", "file", "files", "pdf"},
125
+ 59737: {"folder", "folders", "files"},
126
+ 61464: {"note", "notes", "memo"},
127
+ 59791: {"news", "article", "articles", "rss"},
128
+ }
129
+
130
+ LANGUAGE_HINT_TOKENS = {
131
+ "translate",
132
+ "translation",
133
+ "language",
134
+ "languages",
135
+ "multilingual",
136
+ "dictionary",
137
+ "english",
138
+ "spanish",
139
+ "french",
140
+ "german",
141
+ "italian",
142
+ "japanese",
143
+ "chinese",
144
+ "portuguese",
145
+ "korean",
146
+ }
147
+
148
+
149
+ def _stem_token(token: str) -> str:
150
+ if len(token) <= 3:
151
+ return token
152
+ if token.endswith("ies") and len(token) > 4:
153
+ return token[:-3] + "y"
154
+ if token.endswith("ing") and len(token) > 5:
155
+ return token[:-3]
156
+ if token.endswith("ed") and len(token) > 4:
157
+ return token[:-2]
158
+ if token.endswith("es") and len(token) > 4:
159
+ if token[:-1].endswith("e"):
160
+ return token[:-1]
161
+ return token[:-2]
162
+ if token.endswith("s") and len(token) > 4:
163
+ return token[:-1]
164
+ return token
165
+
166
+
167
+ def _normalize(text: str) -> str:
168
+ text = text.strip()
169
+ text = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
170
+ text = text.lower()
171
+ text = text.replace("_", " ").replace("-", " ")
172
+ text = re.sub(r"[^a-z0-9#+ ]+", " ", text)
173
+ return re.sub(r"\s+", " ", text).strip()
174
+
175
+
176
+ def _tokenize(text: str) -> list[str]:
177
+ norm = _normalize(text)
178
+ base_tokens = [t for t in norm.split() if t and t not in STOP_WORDS]
179
+ tokens: list[str] = []
180
+ seen: set[str] = set()
181
+ for token in base_tokens:
182
+ for candidate in (token, _stem_token(token)):
183
+ if candidate and candidate not in STOP_WORDS and candidate not in seen:
184
+ tokens.append(candidate)
185
+ seen.add(candidate)
186
+ return tokens
187
+
188
+
189
+ def _rgb_from_hex(hex_value: str) -> tuple[int, int, int]:
190
+ value = hex_value.lstrip("#")
191
+ return int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16)
192
+
193
+
194
+ def _distance_rgb(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
195
+ return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
196
+
197
+
198
+ def _load_json(path: Path) -> Any:
199
+ with path.open("r", encoding="utf-8") as handle:
200
+ return json.load(handle)
201
+
202
+
203
+ def _load_data(skill_root: Path) -> tuple[dict[str, str], dict[str, dict[str, Any]], list[dict[str, Any]]]:
204
+ data_dir = skill_root / "data"
205
+ glyph_map = _load_json(data_dir / "shortcuts-official-glyph-mapping.json")
206
+ glyph_synonyms = _load_json(data_dir / "shortcuts-glyph-synonyms.json")
207
+ colors = _load_json(data_dir / "shortcuts-icon-colors.json")
208
+ return glyph_map, glyph_synonyms, colors
209
+
210
+
211
+ def _build_index(glyph_map: dict[str, str], glyph_synonyms: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
212
+ index: list[dict[str, Any]] = []
213
+
214
+ for glyph_str, official_name in glyph_map.items():
215
+ entry = glyph_synonyms.get(glyph_str, {})
216
+ display_name = entry.get("name") or official_name
217
+ synonyms = entry.get("synonyms") or []
218
+
219
+ terms = [display_name, official_name, *synonyms]
220
+ norm_terms: list[str] = []
221
+ seen_terms: set[str] = set()
222
+ for term in terms:
223
+ if not isinstance(term, str):
224
+ continue
225
+ norm = _normalize(term)
226
+ if norm and norm not in seen_terms:
227
+ norm_terms.append(norm)
228
+ seen_terms.add(norm)
229
+
230
+ name_tokens = set(_tokenize(display_name)) | set(_tokenize(official_name))
231
+ syn_tokens: set[str] = set()
232
+ for syn in synonyms:
233
+ if isinstance(syn, str):
234
+ syn_tokens.update(_tokenize(syn))
235
+
236
+ index.append(
237
+ {
238
+ "glyph": int(glyph_str),
239
+ "name": display_name,
240
+ "official_name": official_name,
241
+ "norm_name": _normalize(display_name),
242
+ "terms": norm_terms,
243
+ "name_tokens": name_tokens,
244
+ "syn_tokens": syn_tokens,
245
+ "all_tokens": name_tokens | syn_tokens,
246
+ }
247
+ )
248
+
249
+ return index
250
+
251
+
252
+ def _extract_explicit_glyph(prompt: str, glyph_map: dict[str, str]) -> tuple[int | None, str | None]:
253
+ for pattern in GLYPH_NUMBER_PATTERNS:
254
+ match = pattern.search(prompt)
255
+ if not match:
256
+ continue
257
+ glyph = int(match.group(1))
258
+ if str(glyph) in glyph_map:
259
+ return glyph, f"explicit glyph number {glyph}"
260
+
261
+ return None, None
262
+
263
+
264
+ def _extract_icon_query(prompt: str) -> str | None:
265
+ for pattern in ICON_QUERY_PATTERNS:
266
+ match = pattern.search(prompt)
267
+ if not match:
268
+ continue
269
+ query = match.group(1).strip(" .,!?:;\"'()[]{}")
270
+ if query:
271
+ return query
272
+ return None
273
+
274
+
275
+ def _score_icon(entry: dict[str, Any], query: str, explicit: bool) -> int:
276
+ query_norm = _normalize(query)
277
+ query_tokens = set(_tokenize(query))
278
+ if not query_norm and not query_tokens:
279
+ return 0
280
+
281
+ score = 0
282
+ boost = 1.35 if explicit else 1.0
283
+
284
+ if query_norm:
285
+ if query_norm == entry["norm_name"]:
286
+ score += int(260 * boost)
287
+ if query_norm in entry["terms"]:
288
+ score += int(230 * boost)
289
+ for term in entry["terms"]:
290
+ if term.startswith(query_norm) and query_norm != term:
291
+ score += int(150 * boost)
292
+ elif query_norm in term and query_norm != term:
293
+ score += int(100 * boost)
294
+
295
+ overlap_name = len(query_tokens & entry["name_tokens"])
296
+ overlap_syn = len(query_tokens & entry["syn_tokens"])
297
+ score += int(overlap_name * 34 * boost)
298
+ score += int(overlap_syn * 20 * boost)
299
+
300
+ if query_tokens:
301
+ overlap_all = len(query_tokens & entry["all_tokens"])
302
+ if overlap_all == len(query_tokens):
303
+ score += int(45 * boost)
304
+ elif overlap_all >= 2:
305
+ score += int(20 * boost)
306
+
307
+ icon_hint_terms = ICON_HINTS.get(entry["glyph"], set())
308
+ if icon_hint_terms and query_tokens:
309
+ overlap_hint = len(query_tokens & icon_hint_terms)
310
+ if overlap_hint:
311
+ score += int(overlap_hint * 32 * boost)
312
+ if overlap_hint >= 2:
313
+ score += int(22 * boost)
314
+
315
+ if entry["glyph"] in {59412, 62305} and query_tokens:
316
+ lang_overlap = len(query_tokens & LANGUAGE_HINT_TOKENS)
317
+ if lang_overlap:
318
+ score += int(lang_overlap * 30 * boost)
319
+
320
+ if explicit and query_tokens:
321
+ missing = len(query_tokens - entry["all_tokens"])
322
+ score -= missing * 6
323
+
324
+ return score
325
+
326
+
327
+ def _resolve_icon(
328
+ prompt: str,
329
+ icon_override: str | None,
330
+ glyph_map: dict[str, str],
331
+ index: list[dict[str, Any]],
332
+ top: int,
333
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
334
+ explicit_glyph, explicit_glyph_reason = (None, None)
335
+ if icon_override is None:
336
+ explicit_glyph, explicit_glyph_reason = _extract_explicit_glyph(prompt, glyph_map)
337
+
338
+ if explicit_glyph is not None:
339
+ name = glyph_map[str(explicit_glyph)]
340
+ icon = {
341
+ "glyph_number": explicit_glyph,
342
+ "symbol_name": name,
343
+ "selection_mode": "explicit",
344
+ "reason": explicit_glyph_reason,
345
+ "confidence": 1.0,
346
+ }
347
+ candidates = [{"glyph_number": explicit_glyph, "symbol_name": name, "score": 999}]
348
+ return icon, candidates
349
+
350
+ explicit_query = _normalize(icon_override or "") if icon_override else None
351
+ if not explicit_query:
352
+ explicit_query = _extract_icon_query(prompt)
353
+
354
+ query = explicit_query or prompt
355
+ explicit = bool(explicit_query)
356
+
357
+ scored = []
358
+ for entry in index:
359
+ score = _score_icon(entry, query, explicit)
360
+ if score > 0:
361
+ scored.append((score, entry))
362
+ scored.sort(key=lambda item: item[0], reverse=True)
363
+
364
+ if not scored:
365
+ fallback_name = glyph_map.get(str(DEFAULT_GLYPH), "shortcuts")
366
+ icon = {
367
+ "glyph_number": DEFAULT_GLYPH,
368
+ "symbol_name": fallback_name,
369
+ "selection_mode": "fallback",
370
+ "reason": "no relevant icon match in prompt",
371
+ "confidence": 0.0,
372
+ }
373
+ return icon, [{"glyph_number": DEFAULT_GLYPH, "symbol_name": fallback_name, "score": 0}]
374
+
375
+ top_score, best = scored[0]
376
+ second_score = scored[1][0] if len(scored) > 1 else 0
377
+
378
+ low_confidence = top_score < (120 if explicit else 20)
379
+ tieish = second_score > 0 and top_score / max(second_score, 1) < 1.08
380
+
381
+ if not explicit and low_confidence and tieish:
382
+ fallback_name = glyph_map.get(str(DEFAULT_GLYPH), "shortcuts")
383
+ icon = {
384
+ "glyph_number": DEFAULT_GLYPH,
385
+ "symbol_name": fallback_name,
386
+ "selection_mode": "fallback",
387
+ "reason": "prompt is too generic; defaulting to shortcuts icon",
388
+ "confidence": 0.1,
389
+ }
390
+ else:
391
+ mode = "explicit" if explicit else "inferred"
392
+ reason_query = explicit_query or prompt
393
+ confidence = min(0.99, max(0.2, top_score / 260.0))
394
+ icon = {
395
+ "glyph_number": best["glyph"],
396
+ "symbol_name": best["name"],
397
+ "selection_mode": mode,
398
+ "reason": f"matched query '{reason_query}'",
399
+ "confidence": round(confidence, 3),
400
+ }
401
+
402
+ candidates: list[dict[str, Any]] = []
403
+ for score, entry in scored[: max(1, top)]:
404
+ candidates.append(
405
+ {
406
+ "glyph_number": entry["glyph"],
407
+ "symbol_name": entry["name"],
408
+ "score": score,
409
+ }
410
+ )
411
+
412
+ return icon, candidates
413
+
414
+
415
+ def _extract_color_query(prompt: str, colors: list[dict[str, Any]]) -> str | None:
416
+ if match := HEX_COLOR_RE.search(prompt):
417
+ return f"#{match.group(1)}"
418
+
419
+ color_names = [c["name"].lower() for c in colors]
420
+ name_pattern = "|".join(re.escape(name) for name in color_names)
421
+ patterns = [
422
+ re.compile(rf"\b(?:icon|shortcut)\s*(?:color|colour)\s*(?:to|as|is|=)?\s*({name_pattern})\b", re.I),
423
+ re.compile(rf"\b({name_pattern})\b(?=[^.!?]{{0,24}}\b(?:icon|glyph|symbol|color|colour)\b)", re.I),
424
+ re.compile(rf"\b(?:color|colour)\s+(?:it\s+)?({name_pattern})\b", re.I),
425
+ ]
426
+ for pattern in patterns:
427
+ match = pattern.search(prompt)
428
+ if match:
429
+ return match.group(1)
430
+
431
+ return None
432
+
433
+
434
+ def _resolve_color_by_alias(query: str, colors: list[dict[str, Any]]) -> tuple[dict[str, Any] | None, str | None]:
435
+ normalized = _normalize(query)
436
+ if not normalized:
437
+ return None, None
438
+
439
+ for color in colors:
440
+ if normalized == _normalize(color["name"]):
441
+ return color, f"explicit color name '{query}'"
442
+
443
+ for color in colors:
444
+ for synonym in color.get("synonyms", []):
445
+ if normalized == _normalize(synonym):
446
+ return color, f"explicit color synonym '{query}'"
447
+
448
+ try:
449
+ numeric = int(normalized)
450
+ except ValueError:
451
+ numeric = None
452
+
453
+ if numeric is not None:
454
+ for color in colors:
455
+ if numeric == int(color["value"]) or numeric in [int(v) for v in color.get("aliases", [])]:
456
+ return color, f"explicit color value {numeric}"
457
+
458
+ return None, None
459
+
460
+
461
+ def _resolve_color(
462
+ prompt: str,
463
+ color_override: str | None,
464
+ colors: list[dict[str, Any]],
465
+ icon: dict[str, Any],
466
+ ) -> dict[str, Any]:
467
+ explicit_query = color_override or _extract_color_query(prompt, colors)
468
+
469
+ if explicit_query:
470
+ if explicit_query.startswith("#") and len(explicit_query) == 7:
471
+ target_rgb = _rgb_from_hex(explicit_query)
472
+ closest = min(colors, key=lambda color: _distance_rgb(target_rgb, _rgb_from_hex(color["hex"])))
473
+ return {
474
+ "value": int(closest["value"]),
475
+ "name": closest["name"],
476
+ "hex": closest["hex"],
477
+ "selection_mode": "explicit",
478
+ "reason": f"closest palette color to {explicit_query}",
479
+ }
480
+
481
+ color_match, reason = _resolve_color_by_alias(explicit_query, colors)
482
+ if color_match:
483
+ return {
484
+ "value": int(color_match["value"]),
485
+ "name": color_match["name"],
486
+ "hex": color_match["hex"],
487
+ "selection_mode": "explicit",
488
+ "reason": reason,
489
+ }
490
+
491
+ prompt_tokens = set(_tokenize(prompt))
492
+ icon_tokens = set(_tokenize(icon.get("symbol_name", "")))
493
+
494
+ best_color = None
495
+ best_score = 0
496
+ for color in colors:
497
+ score = 0
498
+ color_name = color["name"]
499
+
500
+ score += len(prompt_tokens & { _normalize(color_name) }) * 20
501
+
502
+ for synonym in color.get("synonyms", []):
503
+ syn_token_set = set(_tokenize(synonym))
504
+ if syn_token_set:
505
+ overlap = len(prompt_tokens & syn_token_set)
506
+ score += overlap * 6
507
+
508
+ for hint in COLOR_HINTS.get(color_name, set()):
509
+ hint_tokens = set(_tokenize(hint))
510
+ if hint_tokens and hint_tokens <= prompt_tokens:
511
+ score += 14
512
+ elif hint_tokens and prompt_tokens & hint_tokens:
513
+ score += 6
514
+
515
+ # A small bias to keep icon + color visually coherent for known themes.
516
+ if color_name == "Purple" and {"robot", "brain", "sparkles"} & icon_tokens:
517
+ score += 8
518
+ if color_name == "Gold" and {"dollar", "dollar sign", "credit card"} & icon_tokens:
519
+ score += 8
520
+ if color_name == "Blue" and {"cloud", "airplane", "map"} & icon_tokens:
521
+ score += 8
522
+ if color_name == "Gray" and {"gear", "tools", "wrench", "command"} & icon_tokens:
523
+ score += 8
524
+
525
+ if score > best_score:
526
+ best_score = score
527
+ best_color = color
528
+
529
+ if best_color is None or best_score == 0:
530
+ default = next((c for c in colors if int(c["value"]) == DEFAULT_COLOR_VALUE), colors[0])
531
+ return {
532
+ "value": int(default["value"]),
533
+ "name": default["name"],
534
+ "hex": default["hex"],
535
+ "selection_mode": "fallback",
536
+ "reason": "no explicit or thematic color match; using default",
537
+ }
538
+
539
+ return {
540
+ "value": int(best_color["value"]),
541
+ "name": best_color["name"],
542
+ "hex": best_color["hex"],
543
+ "selection_mode": "inferred",
544
+ "reason": "matched prompt theme keywords",
545
+ }
546
+
547
+
548
+ def resolve_icon_color(prompt: str, icon_override: str | None, color_override: str | None, top: int = 5) -> dict[str, Any]:
549
+ script_path = Path(__file__).resolve()
550
+ skill_root = script_path.parents[1]
551
+
552
+ glyph_map, glyph_synonyms, colors = _load_data(skill_root)
553
+ index = _build_index(glyph_map, glyph_synonyms)
554
+
555
+ icon, icon_candidates = _resolve_icon(prompt, icon_override, glyph_map, index, top)
556
+ color = _resolve_color(prompt, color_override, colors, icon)
557
+
558
+ return {
559
+ "prompt": prompt,
560
+ "icon": icon,
561
+ "color": color,
562
+ "wf_workflow_icon": {
563
+ "WFWorkflowIconGlyphNumber": int(icon["glyph_number"]),
564
+ "WFWorkflowIconStartColor": int(color["value"]),
565
+ },
566
+ "icon_candidates": icon_candidates,
567
+ "color_palette": [
568
+ {
569
+ "name": c["name"],
570
+ "hex": c["hex"],
571
+ "value": int(c["value"]),
572
+ }
573
+ for c in colors
574
+ ],
575
+ }
576
+
577
+
578
+ def _build_parser() -> argparse.ArgumentParser:
579
+ parser = argparse.ArgumentParser(description="Resolve Shortcuts icon/color from natural language")
580
+ parser.add_argument("--prompt", required=True, help="Original user prompt used to build the shortcut")
581
+ parser.add_argument("--icon", default=None, help="Optional explicit icon query (name/synonym/glyph number)")
582
+ parser.add_argument("--color", default=None, help="Optional explicit color query (name/synonym/value/#RRGGBB)")
583
+ parser.add_argument("--top", type=int, default=5, help="How many icon candidates to include")
584
+ return parser
585
+
586
+
587
+ def main() -> int:
588
+ parser = _build_parser()
589
+ args = parser.parse_args()
590
+
591
+ payload = resolve_icon_color(args.prompt, args.icon, args.color, top=max(1, args.top))
592
+ print(json.dumps(payload, indent=2, ensure_ascii=True))
593
+ return 0
594
+
595
+
596
+ if __name__ == "__main__":
597
+ raise SystemExit(main())