@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.2

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 (112) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +112 -114
  13. package/package.json +5 -2
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -13
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  31. package/pipeline/scripts/log-metric.sh +9 -3
  32. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  33. package/pipeline/scripts/pre-commit-check.sh +119 -27
  34. package/pipeline/scripts/scan-agent-config.sh +9 -9
  35. package/pipeline/scripts/unattended_policy.py +12 -3
  36. package/pipeline/scripts/uninstall.mjs +88 -1
  37. package/pipeline/scripts/usage-identity.mjs +1 -1
  38. package/pipeline/scripts/usage-register.mjs +1 -1
  39. package/pipeline/skills/.skill-manifest.json +30 -30
  40. package/pipeline/skills/shared/README.md +64 -64
  41. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  42. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  43. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  44. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  45. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  46. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  47. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  48. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  49. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  50. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  51. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  53. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  54. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  55. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  56. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  57. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  58. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  59. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  60. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  61. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  62. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
  63. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
  64. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
  65. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
  66. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
  67. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
  68. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
  69. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  70. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
  71. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
  72. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
  73. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  74. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
  75. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
  76. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
  77. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
  78. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
  79. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  80. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  81. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  82. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  83. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  84. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  85. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  86. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  87. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  88. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  89. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  90. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  91. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  92. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  93. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  94. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  95. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  96. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  97. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  98. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  99. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  100. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  101. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  102. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  103. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  104. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  105. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  106. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  107. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  108. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  109. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
  110. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  111. package/pipeline/scripts/make-manifest.mjs +0 -199
  112. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -16,11 +16,16 @@ import subprocess
16
16
  import sys
17
17
  import tempfile
18
18
  import urllib.parse
19
- from xml.sax.saxutils import quoteattr
19
+ from xml.sax.saxutils import escape, quoteattr
20
+
21
+ from _shared import add_token_flag, reject_token_flag
20
22
 
21
23
  EMBED_EXTENSIONS = {".xlsx", ".xls", ".csv", ".docx", ".pdf"}
22
- EMBED_HEADING = "CMS Aktar\u0131m Dosyas\u0131"
24
+ EMBED_HEADING = "CMS Import File"
23
25
  CONTENT = "/rest/api/content"
26
+ CONNECT_TIMEOUT = 15
27
+ REQUEST_TIMEOUT = 120
28
+ PROCESS_TIMEOUT = 150
24
29
 
25
30
 
26
31
  class Client:
@@ -39,7 +44,10 @@ class Client:
39
44
  os.rmdir(self.tmp)
40
45
 
41
46
  def call(self, method, path, body=None, form_file=None):
42
- argv = ["curl", "-sS", "-K", self.config, "-X", method]
47
+ argv = [
48
+ "curl", "-sS", "--connect-timeout", str(CONNECT_TIMEOUT), "--max-time", str(REQUEST_TIMEOUT),
49
+ "-K", self.config, "-X", method,
50
+ ]
43
51
  if body is not None:
44
52
  payload = os.path.join(self.tmp, "body.json")
45
53
  with open(payload, "w", encoding="utf-8") as sink:
@@ -48,7 +56,10 @@ class Client:
48
56
  if form_file is not None:
49
57
  argv += ["-F", "file=@" + form_file, "-H", "X-Atlassian-Token: nocheck"]
50
58
  argv.append(self.base + path)
51
- done = subprocess.run(argv, text=True, capture_output=True)
59
+ try:
60
+ done = subprocess.run(argv, text=True, capture_output=True, timeout=PROCESS_TIMEOUT)
61
+ except subprocess.TimeoutExpired:
62
+ sys.exit(f"curl did not finish within {PROCESS_TIMEOUT}s: {method} {path}")
52
63
  if done.returncode != 0:
53
64
  sys.exit(f"curl did not complete: {done.stderr.strip()}")
54
65
  try:
@@ -60,9 +71,7 @@ class Client:
60
71
  return reply
61
72
 
62
73
 
63
- def resolve_token(explicit, keychain_name):
64
- if explicit:
65
- return explicit
74
+ def resolve_token(keychain_name):
66
75
  if os.environ.get("CONFLUENCE_API_TOKEN"):
67
76
  return os.environ["CONFLUENCE_API_TOKEN"]
68
77
  try:
@@ -82,8 +91,8 @@ def view_file_macro(path):
82
91
  return f'<p><ac:structured-macro ac:name="view-file">{parameter}</ac:structured-macro></p>'
83
92
 
84
93
 
85
- def embed_block(files):
86
- return f"<h2>{EMBED_HEADING}</h2>" + "".join(map(view_file_macro, files))
94
+ def embed_block(files, heading):
95
+ return f"<h2>{escape(heading)}</h2>" + "".join(map(view_file_macro, files))
87
96
 
88
97
 
89
98
  def keyshot_files(manifest_path):
@@ -111,8 +120,9 @@ def build_cli():
111
120
  cli.add_argument("--base", "--base-url", dest="base", default=os.environ.get("CONFLUENCE_BASE_URL"))
112
121
  cli.add_argument("--space", default=os.environ.get("CONFLUENCE_SPACE"))
113
122
  cli.add_argument("--parent", default=os.environ.get("CONFLUENCE_PARENT"), help="id of the parent page")
114
- cli.add_argument("--token", help="Bearer PAT; CONFLUENCE_API_TOKEN or the keychain is safer since other processes can read argv")
123
+ add_token_flag(cli)
115
124
  cli.add_argument("--keychain-name", default="CONFLUENCE_API_TOKEN", help="keychain service that stores the PAT")
125
+ cli.add_argument("--embed-heading", default=EMBED_HEADING, help="heading above the embedded spreadsheet / document previews")
116
126
  cli.add_argument("--dry-run", action="store_true", help="report the planned actions without touching Confluence")
117
127
  return cli
118
128
 
@@ -162,6 +172,10 @@ def upload(client, page_id, path):
162
172
  def main():
163
173
  cli = build_cli()
164
174
  opts = cli.parse_args()
175
+ reject_token_flag(
176
+ cli, opts,
177
+ f"export CONFLUENCE_API_TOKEN, or keep it in the macOS keychain under the --keychain-name service ({opts.keychain_name})",
178
+ )
165
179
  check_inputs(cli, opts)
166
180
  title = opts.title or f"{opts.title_prefix} - {opts.screen}"
167
181
 
@@ -174,7 +188,7 @@ def main():
174
188
  body = stream.read()
175
189
  embedded = [p for p in opts.attach if os.path.splitext(p)[1].lower() in EMBED_EXTENSIONS]
176
190
  if embedded:
177
- body += embed_block(embedded)
191
+ body += embed_block(embedded, opts.embed_heading)
178
192
 
179
193
  if opts.dry_run:
180
194
  plan = {
@@ -184,7 +198,7 @@ def main():
184
198
  print(json.dumps(plan, indent=2, ensure_ascii=False))
185
199
  return
186
200
 
187
- token = resolve_token(opts.token, opts.keychain_name)
201
+ token = resolve_token(opts.keychain_name)
188
202
  if not token:
189
203
  sys.exit(
190
204
  "No Confluence token available. Put a Server/DC personal access token in the keychain "
@@ -145,11 +145,12 @@ def main():
145
145
  cli.add_argument("--nodes", help="frame node-ids separated by commas; falls back to figmaNodes")
146
146
  cli.add_argument("--pad", default=150, type=int, help="pixels of surrounding context kept over and under the element")
147
147
  cli.add_argument("--scale", default=2, type=int)
148
- cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
148
+ ov.add_token_flag(cli)
149
149
  cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
150
150
  cli.add_argument("--out", dest="target_dir", default=".", help="folder for the manifest; the crops go into <out>/keyshots/")
151
151
  cli.add_argument("--slug", help="stem of the manifest name; derived from the screen name when omitted")
152
152
  opts = cli.parse_args()
153
+ ov.reject_token_flag(cli, opts, ov.FIGMA_TOKEN_HINT)
153
154
 
154
155
  with open(opts.mapping, encoding="utf-8") as stream:
155
156
  mapping = json.load(stream)
@@ -163,9 +164,9 @@ def main():
163
164
  nodes = ov.split_nodes(opts.nodes, mapping)
164
165
  if not (file_key and nodes):
165
166
  cli.error("need a Figma file key and frame node-ids (flags or mapping), or --spec")
166
- token = ov.figma_token(opts.token)
167
+ token = ov.figma_token()
167
168
  if not token:
168
- sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN, keychain FIGMA_ACCESS_TOKEN or --token). "
169
+ sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN or keychain FIGMA_ACCESS_TOKEN). "
169
170
  "Without one, build --spec files from the screenshots instead.")
170
171
  pages = rest_pages(file_key, nodes, opts.scale, token, opts.ca_file)
171
172
 
@@ -25,6 +25,10 @@ import urllib.error
25
25
  import urllib.parse
26
26
  import urllib.request
27
27
 
28
+ from _shared import add_token_flag, reject_token_flag, slug
29
+
30
+ FIGMA_TOKEN_HINT = "export FIGMA_ACCESS_TOKEN (or FIGMA_TOKEN), or keep it in the macOS keychain under FIGMA_ACCESS_TOKEN"
31
+
28
32
  API = "https://api.figma.com/v1"
29
33
  SEP = "[:\\-\u2013]"
30
34
  TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
@@ -40,9 +44,10 @@ RIGHT_MARGIN = 40
40
44
  PINK = "#EC1E91"
41
45
  GRAY = "#9aa0a6"
42
46
  BACKGROUND = "#1e1e1e"
43
- LABEL_CMS = "\u0130\u00e7erik (CMS)"
44
- LABEL_PENDING = "\u00d6neri bekleniyor"
45
- LABEL_UNMATCHED = "(e\u015fle\u015fmedi)"
47
+ LABELS = {
48
+ "en": {"cms": "CMS copy", "pending": "Awaiting copy", "unmatched": "(unmatched)"},
49
+ "tr": {"cms": "\u0130\u00e7erik (CMS)", "pending": "\u00d6neri bekleniyor", "unmatched": "(e\u015fle\u015fmedi)"},
50
+ }
46
51
 
47
52
  PAGE_OPEN = "<!doctype html><html><head>"
48
53
  META_UTF8 = '<meta charset="utf-8">'
@@ -60,13 +65,12 @@ PATH_CHROMES = (
60
65
  )
61
66
 
62
67
 
63
- def canon_id(node_id):
64
- return str(node_id or "").strip().replace("-", ":")
68
+ URL_NODE_ID = re.compile(r"^\d+-\d+$")
65
69
 
66
70
 
67
- def slug(text):
68
- value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
69
- return value or "screen"
71
+ def canon_id(node_id):
72
+ text = str(node_id or "").strip()
73
+ return text.replace("-", ":") if URL_NODE_ID.match(text) else text
70
74
 
71
75
 
72
76
  def split_lang(text):
@@ -135,9 +139,7 @@ def keychain_secret(service):
135
139
  return secret if lookup.returncode == 0 and secret else None
136
140
 
137
141
 
138
- def figma_token(explicit):
139
- if explicit:
140
- return explicit
142
+ def figma_token():
141
143
  from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
142
144
  return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
143
145
 
@@ -248,7 +250,7 @@ def page_from_rest(file_key, node_id, scale, token, ca_file):
248
250
  }
249
251
 
250
252
 
251
- def build_cards(page, rows):
253
+ def build_cards(page, rows, labels):
252
254
  index = build_index(rows)
253
255
  cards = []
254
256
  taken = set()
@@ -273,7 +275,7 @@ def build_cards(page, rows):
273
275
  box = {axis: float(node.get(axis, 0)) for axis in ("x", "y", "w", "h")}
274
276
  cards.append({
275
277
  "num": number,
276
- "key": new_key or LABEL_UNMATCHED,
278
+ "key": new_key or labels["unmatched"],
277
279
  "tr": tr,
278
280
  "en": en,
279
281
  "text": node.get("characters") or "",
@@ -302,9 +304,9 @@ def chip(kind, colour, content):
302
304
  return f'<span class="{kind}" style="background:{colour}">{content}</span>'
303
305
 
304
306
 
305
- def card_html(card, left):
307
+ def card_html(card, left, labels):
306
308
  colour = PINK if card["annotated"] else GRAY
307
- pill = LABEL_CMS if card["annotated"] else LABEL_PENDING
309
+ pill = labels["cms"] if card["annotated"] else labels["pending"]
308
310
  if card["annotated"]:
309
311
  body = "".join(
310
312
  f'<div class="line"><b>{label}</b> {html.escape(card[lang] or "-")}</div>'
@@ -336,7 +338,7 @@ def connector(card, lane, lane_count, start_x, target_x, from_left):
336
338
  )
337
339
 
338
340
 
339
- def build_html(page, cards):
341
+ def build_html(page, cards, labels):
340
342
  fw, fh = page["frame"]["w"], page["frame"]["h"]
341
343
  sides = {"left": [], "right": []}
342
344
  for card in cards:
@@ -350,7 +352,7 @@ def build_html(page, cards):
350
352
  for card_x, group in ((COLUMN_X, sides["left"]), (right_x, sides["right"])):
351
353
  from_left = card_x == COLUMN_X
352
354
  for lane, card in enumerate(group):
353
- boxes.append(card_html(card, card_x))
355
+ boxes.append(card_html(card, card_x, labels))
354
356
  if from_left:
355
357
  wire = connector(card, lane, len(group), COLUMN_X + CARD_W, screen_x + card["x"], True)
356
358
  else:
@@ -430,12 +432,26 @@ def chrome_screenshot(chrome, html_text, out_png, width, height, scale):
430
432
  raise RuntimeError((log.read() or "no output").strip()[-300:])
431
433
 
432
434
 
435
+ def is_number(value):
436
+ try:
437
+ float(value)
438
+ except (TypeError, ValueError):
439
+ return False
440
+ return True
441
+
442
+
433
443
  def load_spec_pages(path):
434
444
  with open(path, encoding="utf-8") as stream:
435
445
  spec = json.load(stream)
436
446
  folder = os.path.dirname(os.path.abspath(path))
437
447
  pages = spec.get("pages") or []
438
- for page in pages:
448
+ for number, page in enumerate(pages, 1):
449
+ frame = page.get("frame") if isinstance(page, dict) else None
450
+ if not (isinstance(frame, dict) and all(is_number(frame.get(side)) for side in ("w", "h"))):
451
+ sys.exit(f"{path}: page {number} needs \"frame\": {{\"w\": <width>, \"h\": <height>}} in px")
452
+ for side in ("w", "h"):
453
+ if not isinstance(frame[side], (int, float)):
454
+ frame[side] = float(frame[side])
439
455
  page["image"] = image_source(page.get("image"), folder)
440
456
  return spec, pages
441
457
 
@@ -445,18 +461,26 @@ def split_nodes(flag, mapping):
445
461
  return given or list(mapping.get("figmaNodes") or [])
446
462
 
447
463
 
448
- def main():
464
+ def build_cli():
449
465
  cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
450
466
  cli.add_argument("--mapping", required=True, help="the reuse-map JSON")
451
467
  cli.add_argument("--spec", help="geometry from MCP or measured by hand: {fileKey, pages:[{nodeId, image, frame, nodes}]}")
452
468
  cli.add_argument("--file", help="Figma file key; falls back to figmaFileKey in the mapping")
453
469
  cli.add_argument("--nodes", help="node-ids separated by commas, the screen frame first; falls back to figmaNodes")
454
470
  cli.add_argument("--scale", default=2, type=int)
455
- cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
471
+ add_token_flag(cli)
456
472
  cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
457
473
  cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
458
474
  cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
475
+ cli.add_argument("--ui-lang", choices=sorted(LABELS), default="en", help="language of the card labels")
476
+ return cli
477
+
478
+
479
+ def main():
480
+ cli = build_cli()
459
481
  opts = cli.parse_args()
482
+ reject_token_flag(cli, opts, FIGMA_TOKEN_HINT)
483
+ labels = LABELS[opts.ui_lang]
460
484
 
461
485
  with open(opts.mapping, encoding="utf-8") as stream:
462
486
  mapping = json.load(stream)
@@ -467,20 +491,23 @@ def main():
467
491
  if not pages:
468
492
  sys.exit("--spec lists no pages")
469
493
  page = pages[0]
494
+ if len(pages) > 1:
495
+ print(f"# warning: {len(pages) - 1} extra page(s) in --spec ignored; the overlay draws the first page only "
496
+ "(render-key-shots.py reads every page)", file=sys.stderr)
470
497
  file_key = spec.get("fileKey") or mapping.get("figmaFileKey")
471
498
  else:
472
499
  file_key = opts.file or mapping.get("figmaFileKey")
473
500
  nodes = split_nodes(opts.nodes, mapping)
474
501
  if not (file_key and nodes):
475
502
  cli.error("need a Figma file key and frame node-id (flags or mapping), or --spec")
476
- token = figma_token(opts.token)
503
+ token = figma_token()
477
504
  if not token:
478
- sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN, keychain FIGMA_ACCESS_TOKEN or --token). "
505
+ sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN or keychain FIGMA_ACCESS_TOKEN). "
479
506
  "Without one, measure the boxes on the screenshot and hand them over with --spec.")
480
507
  page = page_from_rest(file_key, canon_id(nodes[0]), opts.scale, token, opts.ca_file)
481
508
 
482
- cards = build_cards(page, mapping.get("rows") or [])
483
- document, width, height = build_html(page, cards)
509
+ cards = build_cards(page, mapping.get("rows") or [], labels)
510
+ document, width, height = build_html(page, cards, labels)
484
511
  os.makedirs(opts.target_dir, exist_ok=True)
485
512
 
486
513
  def output(suffix):
@@ -13,17 +13,25 @@ import plistlib
13
13
  import sys
14
14
  import urllib.request
15
15
 
16
- ALL_LANGS = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
16
+ from _shared import parse_langs
17
+
17
18
 
18
19
 
19
20
  def split_csv(value):
20
21
  return [part.strip() for part in value.split(",") if part.strip()]
21
22
 
22
23
 
23
- def parse_langs(value):
24
- if value.strip() == "all":
25
- return list(ALL_LANGS)
26
- return split_csv(value)
24
+ def present_langs(snapshot_root, plist_root):
25
+ found = []
26
+ if snapshot_root and os.path.isdir(snapshot_root):
27
+ found += [name[:-5] for name in os.listdir(snapshot_root) if name.endswith(".json")]
28
+ if plist_root and os.path.isdir(plist_root):
29
+ for name in os.listdir(plist_root):
30
+ if name.endswith(".lproj") and os.path.isfile(os.path.join(plist_root, name, "language.plist")):
31
+ found.append(name[: -len(".lproj")])
32
+ elif name.endswith(".plist") and name != "language.plist":
33
+ found.append(name[: -len(".plist")])
34
+ return found
27
35
 
28
36
 
29
37
  def parse_pairs(pairs, flag):
@@ -133,7 +141,7 @@ def from_live(args, keys, langs):
133
141
  def main():
134
142
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
135
143
  parser.add_argument("--keys", required=True, help="legacy keys separated by commas; leave off the backend prefix")
136
- parser.add_argument("--langs", default="en,tr", help="comma list or 'all'")
144
+ parser.add_argument("--langs", default="all", help="comma list, or 'all' for every language the snapshot or plist root holds")
137
145
  parser.add_argument("--snapshot-root", help="directory holding <lang>.json flat snapshots")
138
146
  parser.add_argument("--live", action="store_true", help="query the label endpoint instead of the snapshot")
139
147
  parser.add_argument("--endpoint", help="URL template with {lang} and optional {env}")
@@ -144,11 +152,13 @@ def main():
144
152
  parser.add_argument("--fail-status", action="append", help="CODE=message treated as a failure (repeatable)")
145
153
  parser.add_argument("--timeout", type=int, default=30)
146
154
  parser.add_argument("--plist-root", help="last resort: directory of bundled <lang>.lproj/language.plist")
147
- parser.add_argument("--prefix", default="", help="backend key prefix the app strips, e.g. Mobile-")
155
+ parser.add_argument("--prefix", default="", help="backend key prefix the app strips, e.g. App-")
148
156
  args = parser.parse_args()
149
157
 
150
158
  keys = split_csv(args.keys)
151
- langs = parse_langs(args.langs)
159
+ langs = parse_langs(args.langs, present_langs(args.snapshot_root, args.plist_root))
160
+ if not (args.snapshot_root or args.plist_root or args.endpoint):
161
+ sys.exit("no source given: pass --snapshot-root, --plist-root, or --live with --endpoint")
152
162
 
153
163
  if args.snapshot_root and not args.live:
154
164
  result = from_snapshot(args.snapshot_root, keys, langs, args.prefix)
@@ -165,7 +175,7 @@ def main():
165
175
  elif args.plist_root:
166
176
  result = from_plist(args.plist_root, keys, langs, args.prefix)
167
177
  else:
168
- raise
178
+ sys.exit("no --snapshot-root or --plist-root to fall back on")
169
179
 
170
180
  json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
171
181
  sys.stdout.write("\n")
@@ -11,7 +11,8 @@ import json
11
11
  import os
12
12
  import sys
13
13
 
14
- ALL_LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
14
+ from _shared import normalize_key, parse_langs
15
+
15
16
 
16
17
  DOMAIN_DIRS = {
17
18
  "localization": ("Suggested", "LocalizationStrings"),
@@ -19,16 +20,6 @@ DOMAIN_DIRS = {
19
20
  }
20
21
 
21
22
 
22
- def parse_langs(value):
23
- if value.strip() == "all":
24
- return list(ALL_LANGS)
25
- return [part.strip() for part in value.split(",") if part.strip()]
26
-
27
-
28
- def normalize_key(key):
29
- return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
30
-
31
-
32
23
  def read_keys(opts):
33
24
  raw = [piece.strip() for piece in opts.keys.split(",")] if opts.keys else []
34
25
  if opts.keys_file:
@@ -84,13 +75,29 @@ def read_suggested(path, langs):
84
75
  return {lang: value_of(lang) for lang in langs}
85
76
 
86
77
 
78
+ def present_langs(directory, catalog, keys):
79
+ found = []
80
+ for key in keys:
81
+ path = os.path.join(directory, normalize_key(key) + ".json")
82
+ if os.path.isfile(path):
83
+ data = read_json(path)
84
+ found.extend(data.keys() if isinstance(data, dict) else [])
85
+ for candidate in dict.fromkeys((key, normalize_key(key))):
86
+ entry = (catalog or {}).get(candidate)
87
+ if isinstance(entry, dict):
88
+ found.extend(entry.keys())
89
+ return found
90
+
91
+
87
92
  def from_catalog(catalog, key, langs):
88
- entry = catalog.get(key)
89
- if entry is None:
90
- entry = catalog.get(normalize_key(key))
91
- if not isinstance(entry, dict):
92
- return None
93
- return {lang: entry.get(lang) for lang in langs}
93
+ for candidate in dict.fromkeys((key, normalize_key(key))):
94
+ entry = catalog.get(candidate)
95
+ if not isinstance(entry, dict):
96
+ continue
97
+ values = {lang: entry.get(lang) for lang in langs}
98
+ if any(value is not None for value in values.values()):
99
+ return values
100
+ return None
94
101
 
95
102
 
96
103
  def main():
@@ -102,7 +109,7 @@ def main():
102
109
  cli.add_argument("--domain", default="localization", choices=sorted(DOMAIN_DIRS))
103
110
  cli.add_argument("--catalog", help="string catalog the app ships (.xcstrings or flat JSON), consulted when a key has no Suggested file")
104
111
  cli.add_argument("--report-source", action="store_true", help="return {source, values} per key instead of the bare values")
105
- cli.add_argument("--langs", default="en,tr", help="languages separated by commas, or 'all'")
112
+ cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all' for every language the sources hold")
106
113
  opts = cli.parse_args()
107
114
 
108
115
  keys = read_keys(opts)
@@ -114,8 +121,8 @@ def main():
114
121
  if not os.path.isdir(directory):
115
122
  cli.error(f"no Suggested folder at {directory} (check --resources-root / --suggested-dir / --domain)")
116
123
 
117
- langs = parse_langs(opts.langs)
118
124
  catalog = load_catalog(opts.catalog) if opts.catalog else None
125
+ langs = parse_langs(opts.langs, present_langs(directory, catalog, keys) if opts.langs.strip() == "all" else ())
119
126
 
120
127
  result, via_catalog, unresolved = {}, [], []
121
128
  for key in keys:
@@ -13,38 +13,28 @@ import os
13
13
  import re
14
14
  import sys
15
15
 
16
- EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
16
+ from _shared import (
17
+ ACCESSOR_EXTENSIONS, DEFAULT_KEY_TYPES, IDENT, WEB_DOLLAR_T, WEB_T, WORD, is_interpolated, key_type_alternation,
18
+ key_type_warning, key_types, lsk_keys, source_files,
19
+ )
20
+
17
21
  ANDROID = {".kt", ".kts", ".xml"}
18
22
  WEB = {".ts", ".tsx", ".js", ".jsx", ".vue"}
19
- SKIP_PARTS = ("/.git", "/Generated/")
20
23
 
21
24
  ERROR_WORDS = ("toast", "snackbar", "alert", "warning", "failure", "exception", "showerror", "errormessage", "error", "validat")
22
25
  ERROR_CONTEXT = re.compile("|".join(ERROR_WORDS), re.IGNORECASE)
23
26
 
24
- WORD = "A-Za-z0-9_"
25
- IDENT = rf"[A-Za-z_][{WORD}]*"
26
- LSK = re.compile(rf"LocalizationStringKey((?:\.{IDENT}){{2,}})")
27
27
  LITERAL_LOCALIZED = re.compile(r"\"([^\"\\\n]+)\"\.localized\b")
28
28
  R_STRING = re.compile(rf"\bR\.string\.([{WORD}]+)")
29
29
  XML_STRING = re.compile(rf"@string/([{WORD}.]+)")
30
- WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
31
- WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
32
30
  WEB_T_DYNAMIC = re.compile(r"(?<![\w$])t\(\s*[`$A-Za-z_]")
33
31
  WEB_DOLLAR_T_DYNAMIC = re.compile(r"\$t\(\s*[`$A-Za-z_]")
34
- LSK_DYNAMIC = re.compile(r"LocalizationStringKey\(")
35
32
  FORMAT_DYNAMIC = re.compile(r"String\(format:")
36
33
  GETSTRING_DYNAMIC = re.compile(r"\bgetString\(\s*[A-Za-z_]")
37
34
  INTERPOLATED_LOCALIZED = re.compile(r"\"([^\"\n]*(?:\\\(|\$\{|\$[A-Za-z_])[^\"\n]*)\"\.localized\b")
38
35
  RECEIVER_LOCALIZED = re.compile(rf"(?<![\w.\"])({IDENT}(?:\.{IDENT})*)\.localized\b")
39
36
 
40
37
 
41
- def strip_accessor(chain):
42
- segments = chain.strip(".").split(".")
43
- while segments and segments[-1].startswith("localized"):
44
- segments.pop()
45
- return ".".join(segments)
46
-
47
-
48
38
  def platform_of(path):
49
39
  ext = os.path.splitext(path)[1]
50
40
  if ext in ANDROID:
@@ -54,21 +44,11 @@ def platform_of(path):
54
44
  return "ios"
55
45
 
56
46
 
57
- def source_files(root):
58
- if os.path.isfile(root):
59
- yield root
60
- return
61
- for current, dirs, files in os.walk(root):
62
- probe = current.replace(os.sep, "/") + "/"
63
- if any(part in probe for part in SKIP_PARTS):
64
- dirs[:] = []
65
- continue
66
- for name in sorted(files):
67
- if os.path.splitext(name)[1] in EXTENSIONS:
68
- yield os.path.join(current, name)
47
+ def dynamic_key_pattern(types):
48
+ return re.compile(rf"({key_type_alternation(types)})\(")
69
49
 
70
50
 
71
- def hits_in_line(line, ext):
51
+ def hits_in_line(line, ext, types=DEFAULT_KEY_TYPES):
72
52
  static, dynamic = [], []
73
53
  if ext == ".xml":
74
54
  static.extend(m.group(1) for m in XML_STRING.finditer(line))
@@ -81,15 +61,13 @@ def hits_in_line(line, ext):
81
61
  if WEB_DOLLAR_T_DYNAMIC.search(line):
82
62
  dynamic.append("(dynamic: $t(...))")
83
63
  return static, dynamic
84
- for match in LSK.finditer(line):
85
- key = strip_accessor(match.group(1))
86
- if key.count(".") >= 1:
87
- static.append(key)
88
- static.extend(m.group(1) for m in LITERAL_LOCALIZED.finditer(line))
64
+ static.extend(lsk_keys(line, types))
65
+ static.extend(m.group(1) for m in LITERAL_LOCALIZED.finditer(line) if not is_interpolated(m.group(1)))
89
66
  static.extend(m.group(1) for m in R_STRING.finditer(line))
90
- if LSK_DYNAMIC.search(line):
91
- dynamic.append("(dynamic: LocalizationStringKey(...))")
92
- mentions_key = any(marker in line for marker in (".localized", "LocalizationStringKey"))
67
+ constructed = dynamic_key_pattern(types).search(line)
68
+ if constructed:
69
+ dynamic.append(f"(dynamic: {constructed.group(1)}(...))")
70
+ mentions_key = any(marker in line for marker in (".localized", *types))
93
71
  if mentions_key and FORMAT_DYNAMIC.search(line):
94
72
  dynamic.append("(dynamic: String(format:...))")
95
73
  if "R.string." not in line and GETSTRING_DYNAMIC.search(line):
@@ -98,26 +76,30 @@ def hits_in_line(line, ext):
98
76
  dynamic.append(f'(dynamic: "{match.group(1)}".localized)')
99
77
  for match in RECEIVER_LOCALIZED.finditer(line):
100
78
  receiver = match.group(1)
101
- if not receiver.startswith("LocalizationStringKey"):
79
+ if not receiver.startswith(tuple(types)):
102
80
  dynamic.append(f"(dynamic: {receiver}.localized)")
103
81
  return static, dynamic
104
82
 
105
83
 
106
- def scan(root):
84
+ def scan(root, types=DEFAULT_KEY_TYPES):
107
85
  keys = []
108
86
  seen = set()
87
+ accessor_texts = []
109
88
  scanned = 0
110
89
  for path in source_files(root):
111
90
  try:
112
91
  with open(path, encoding="utf-8") as stream:
113
- lines = stream.read().splitlines()
92
+ text = stream.read()
114
93
  except (OSError, UnicodeDecodeError):
115
94
  continue
95
+ lines = text.splitlines()
116
96
  scanned += 1
117
97
  ext = os.path.splitext(path)[1]
98
+ if ext in ACCESSOR_EXTENSIONS:
99
+ accessor_texts.append(text)
118
100
  platform = platform_of(path)
119
101
  for number, line in enumerate(lines, 1):
120
- static, dynamic = hits_in_line(line, ext)
102
+ static, dynamic = hits_in_line(line, ext, types)
121
103
  in_error = bool(ERROR_CONTEXT.search(line))
122
104
  found = [(k, "error" if in_error else "static") for k in static] + [(k, "dynamic") for k in dynamic]
123
105
  for key, category in found:
@@ -133,7 +115,7 @@ def scan(root):
133
115
  "line": number,
134
116
  "snippet": line.strip()[:200],
135
117
  })
136
- return scanned, keys
118
+ return scanned, keys, key_type_warning(accessor_texts, types)
137
119
 
138
120
 
139
121
  CATEGORIES = ("static", "error", "dynamic")
@@ -144,22 +126,31 @@ def main():
144
126
  cli.add_argument("--screen-path", required=True, help="folder or single file of the built screen")
145
127
  cli.add_argument("--out", help="JSON destination; stdout when omitted")
146
128
  cli.add_argument("--category", default="all", choices=["all", "error", "dynamic", "static"])
129
+ cli.add_argument(
130
+ "--key-type", action="append", metavar="NAME",
131
+ help="type whose member chains are keys, e.g. the project's generated key enum; repeatable or comma list "
132
+ f"(default {', '.join(DEFAULT_KEY_TYPES)})",
133
+ )
147
134
  opts = cli.parse_args()
148
135
 
149
136
  if not os.path.exists(opts.screen_path):
150
137
  cli.error(f"--screen-path not found: {opts.screen_path}")
138
+ try:
139
+ types = key_types(opts.key_type)
140
+ except ValueError as exc:
141
+ cli.error(str(exc))
151
142
 
152
- scanned, keys = scan(opts.screen_path)
143
+ scanned, keys, type_warning = scan(opts.screen_path, types)
153
144
  if opts.category != "all":
154
145
  keys = [entry for entry in keys if entry["category"] == opts.category]
155
146
  keys.sort(key=lambda entry: (entry["category"], entry["file"], entry["line"]))
156
147
  per_category = [entry["category"] for entry in keys]
157
148
  counts = {name: per_category.count(name) for name in CATEGORIES}
158
149
 
159
- text = json.dumps(
160
- {"screenPath": opts.screen_path, "filesScanned": scanned, "counts": counts, "keys": keys},
161
- indent=2, ensure_ascii=False,
162
- ) + "\n"
150
+ report = {"screenPath": opts.screen_path, "filesScanned": scanned, "counts": counts, "keys": keys}
151
+ if type_warning:
152
+ report["keyTypeWarning"] = type_warning
153
+ text = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
163
154
  if opts.out:
164
155
  with open(opts.out, "w", encoding="utf-8") as sink:
165
156
  sink.write(text)
@@ -167,6 +158,8 @@ def main():
167
158
  sys.stdout.write(text)
168
159
  summary = ", ".join(f"{counts[name]} {name}" for name in CATEGORIES)
169
160
  print(f"# {scanned} file(s) read: {summary}", file=sys.stderr)
161
+ if type_warning:
162
+ print(f"# {type_warning}", file=sys.stderr)
170
163
 
171
164
 
172
165
  if __name__ == "__main__":