@mmerterden/multi-agent-pipeline 20.8.1 → 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 (75) hide show
  1. package/CHANGELOG.md +36 -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 +75 -78
  13. package/package.json +4 -1
  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 -14
  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/log-metric.sh +9 -3
  31. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  32. package/pipeline/scripts/pre-commit-check.sh +119 -31
  33. package/pipeline/scripts/scan-agent-config.sh +9 -9
  34. package/pipeline/scripts/unattended_policy.py +12 -3
  35. package/pipeline/scripts/uninstall.mjs +88 -1
  36. package/pipeline/scripts/usage-identity.mjs +1 -1
  37. package/pipeline/scripts/usage-register.mjs +1 -1
  38. package/pipeline/skills/.skill-manifest.json +11 -11
  39. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  40. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  41. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  42. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  43. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  44. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +3 -3
  45. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +2 -1
  46. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +14 -13
  47. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +2 -2
  48. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +7 -7
  49. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +105 -4
  50. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +20 -5
  51. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +8 -7
  52. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -8
  53. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +20 -7
  54. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  55. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +8 -7
  56. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +15 -8
  57. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +17 -9
  58. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -17
  59. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +28 -7
  60. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  61. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  62. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  63. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  64. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  65. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  66. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  67. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  68. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  69. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  70. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  71. package/pipeline/skills/shared/external/skill-creator/template.md +1 -1
  72. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  73. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  74. package/pipeline/scripts/make-manifest.mjs +0 -199
  75. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -4,17 +4,23 @@ Every script that scans keys, names output files or orders languages imports
4
4
  this module, so the scanner, the gate, the CMS sheet and the overlay agree on
5
5
  what a wired key is, which rows are component-owned or dynamic, which files are
6
6
  source, how a screen name becomes a file slug and which order languages take.
7
+ The key accessor type (`--key-type`, default `L10n`) and the language list
8
+ (`--langs`, default the languages present in the data) are inputs, never a
9
+ built-in list of one app's.
7
10
  """
11
+ import argparse
8
12
  import os
9
13
  import re
10
14
 
11
15
  EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
12
16
  SKIP_DIRS = {".git", "Generated"}
13
- LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
17
+ FALLBACK_LANG = "en"
18
+ RTL_LANGS = {"ar", "fa", "he", "ur"}
14
19
 
15
20
  WORD = "A-Za-z0-9_"
16
21
  IDENT = rf"[A-Za-z_][{WORD}]*"
17
- LSK = re.compile(rf"LocalizationStringKey((?:\.{IDENT})+)")
22
+ DEFAULT_KEY_TYPES = ("L10n",)
23
+ KEY_TYPE_NAME = re.compile(rf"^{IDENT}$")
18
24
  LITERAL_KEY = re.compile(rf"\"([{WORD}.]+)\"\.localized\b")
19
25
  WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
20
26
  WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
@@ -28,6 +34,13 @@ RUNTIME_NOTE = "enumerate at runtime"
28
34
 
29
35
  XML_INVALID = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
30
36
 
37
+ ACCESSOR_EXTENSIONS = {".swift", ".kt", ".kts"}
38
+ COMMON_ACCESSORS = (
39
+ "L10n", "LocalizationKey", "LocalizationKeys",
40
+ "LocalizedStringKey", "LocalizedStrings", "Localizable", "Localization", "Strings", "Loc",
41
+ )
42
+ ACCESSOR_CHAIN = re.compile(rf"(?<![{WORD}.])([A-Z][{WORD}]*)((?:\.{IDENT}){{2,}})")
43
+
31
44
 
32
45
  def strip_accessor(chain):
33
46
  segments = chain.strip(".").split(".")
@@ -36,13 +49,101 @@ def strip_accessor(chain):
36
49
  return ".".join(segments)
37
50
 
38
51
 
39
- def lsk_keys(text):
40
- for hit in LSK.finditer(text):
52
+ def key_types(values, fallback=DEFAULT_KEY_TYPES):
53
+ """The accessor type names from repeated or comma-separated --key-type values.
54
+
55
+ Raises ValueError on a name that is not an identifier.
56
+ """
57
+ names = [part.strip() for value in values or [] for part in str(value).split(",") if part.strip()]
58
+ for name in names:
59
+ if not KEY_TYPE_NAME.match(name):
60
+ raise ValueError(f"--key-type expects an identifier, got {name!r}")
61
+ return tuple(dict.fromkeys(names)) or tuple(fallback)
62
+
63
+
64
+ def key_type_alternation(types):
65
+ return "|".join(re.escape(name) for name in sorted(types, key=len, reverse=True))
66
+
67
+
68
+ def key_chain_pattern(types):
69
+ return re.compile(rf"(?:{key_type_alternation(types)})((?:\.{IDENT})+)")
70
+
71
+
72
+ def lsk_keys(text, types=DEFAULT_KEY_TYPES):
73
+ for hit in key_chain_pattern(types).finditer(text):
41
74
  key = strip_accessor(hit.group(1))
42
75
  if key.count(".") >= 1:
43
76
  yield key
44
77
 
45
78
 
79
+ def accessor_roots(texts, types=DEFAULT_KEY_TYPES):
80
+ """Roots of key-like member chains that are not one of `types`, with their counts.
81
+
82
+ A chain counts when its root is a common accessor name or when it ends in
83
+ `.localized`, and it keeps two or more segments once that suffix is dropped.
84
+ """
85
+ counts = {}
86
+ for text in texts:
87
+ for hit in ACCESSOR_CHAIN.finditer(text):
88
+ root, members = hit.group(1), hit.group(2)
89
+ if root in types:
90
+ continue
91
+ segments = members.strip(".").split(".")
92
+ localized = segments[-1].startswith("localized")
93
+ if strip_accessor(members).count(".") < 1:
94
+ continue
95
+ if root in COMMON_ACCESSORS or localized:
96
+ counts[root] = counts.get(root, 0) + 1
97
+ return dict(sorted(counts.items(), key=lambda item: (-item[1], item[0])))
98
+
99
+
100
+ def key_type_warning(texts, types=DEFAULT_KEY_TYPES, mapping_hint=False):
101
+ """A warning when no chain of `types` is found but chains of another accessor are.
102
+
103
+ `texts` are the Swift and Kotlin sources read. Returns None when the
104
+ configured type matched at least once or nothing resembles an accessor.
105
+ """
106
+ texts = list(texts)
107
+ if any(True for text in texts for _ in lsk_keys(text, types)):
108
+ return None
109
+ roots = accessor_roots(texts, types)
110
+ if not roots:
111
+ return None
112
+ seen = ", ".join(f"{root} ({count})" for root, count in list(roots.items())[:5])
113
+ where = "--key-type <Name> (or \"keyType\" in the mapping)" if mapping_hint else "--key-type <Name>"
114
+ return (
115
+ f"WARNING: no key chain of {', '.join(types)} was found, but the code has accessor chains rooted at "
116
+ f"{seen}. Set {where} to the project's key accessor type; until then its keys are "
117
+ f"missed or counted as weak."
118
+ )
119
+
120
+
121
+ def order_langs(langs):
122
+ """en first when present, then the rest sorted; en alone when nothing is given."""
123
+ unique = list(dict.fromkeys(lang for lang in langs if lang))
124
+ rest = sorted(lang for lang in unique if lang != FALLBACK_LANG)
125
+ ordered = ([FALLBACK_LANG] if FALLBACK_LANG in unique else []) + rest
126
+ return ordered or [FALLBACK_LANG]
127
+
128
+
129
+ def parse_langs(value, present=()):
130
+ """A comma list in the order given, or for 'all' every language in `present`."""
131
+ text = str(value or "all").strip()
132
+ if text == "all":
133
+ return order_langs(present)
134
+ return [part.strip() for part in text.split(",") if part.strip()]
135
+
136
+
137
+ def reject_token_flag(cli, opts, env_hint):
138
+ """Exit 2 when a credential was passed on argv, which other processes can read."""
139
+ if getattr(opts, "token", None) is not None:
140
+ cli.error(f"--token is not accepted, because other processes can read argv; {env_hint}")
141
+
142
+
143
+ def add_token_flag(cli):
144
+ cli.add_argument("--token", help=argparse.SUPPRESS)
145
+
146
+
46
147
  def is_interpolated(literal):
47
148
  return bool(INTERPOLATION.search(literal))
48
149
 
@@ -23,7 +23,7 @@ import zipfile
23
23
  from collections import namedtuple
24
24
  from xml.sax.saxutils import escape, quoteattr
25
25
 
26
- from _shared import LANGS, slug, xml_text
26
+ from _shared import RTL_LANGS, order_langs, parse_langs, slug, xml_text
27
27
 
28
28
  WARN = "\u26a0\ufe0f"
29
29
  DASH = "-"
@@ -210,14 +210,23 @@ def storage_image(filename, dimension, pixels):
210
210
  return node("ac:image", [(f"ac:{dimension}", pixels)], target)
211
211
 
212
212
 
213
+ def mapping_langs(rows):
214
+ found = []
215
+ for row in rows:
216
+ for side in ("new", "legacy", "cms"):
217
+ found.extend((row.get(side) or {}).keys())
218
+ return order_langs(found)
219
+
220
+
213
221
  class Doc:
214
- def __init__(self, mapping, t, prefix, shots, out_dir):
222
+ def __init__(self, mapping, t, prefix, shots, out_dir, langs=None):
215
223
  self.m = mapping
216
224
  self.t = t
217
225
  self.prefix = prefix
218
226
  self.shots = shots
219
227
  self.out = out_dir
220
228
  self.rows = mapping.get("rows") or []
229
+ self.langs = list(langs) if langs else mapping_langs(self.rows)
221
230
  self.counts = {v: sum(1 for r in self.rows if r.get("verdict") == v) for v in VERDICTS}
222
231
  self.cms_rows = sum(1 for r in self.rows if (r.get("cms") or {}).get("tr") or (r.get("cms") or {}).get("en"))
223
232
  self.drift_rows = sum(1 for r in self.rows if row_drifts(r))
@@ -246,7 +255,7 @@ class Doc:
246
255
  ]
247
256
 
248
257
  def detail_langs(self, row):
249
- return LANGS + [lang for lang in (row.get("cms") or {}) if lang not in LANGS]
258
+ return self.langs + [lang for lang in (row.get("cms") or {}) if lang not in self.langs]
250
259
 
251
260
  def md(self):
252
261
  t, m = self.t, self.m
@@ -572,7 +581,7 @@ def detail_blocks(doc, t):
572
581
  blocks.append(para(f"{t['key']}: {row.get('newKey') or ''}"))
573
582
  lines = [table_row(tcell(c, fill="F5F6F8") for c in t["detail_columns"])]
574
583
  for lang in doc.detail_langs(row):
575
- lines.append(table_row(tcell(v, rtl=lang == "ar") for v in (lang, *lang_values(row, lang))))
584
+ lines.append(table_row(tcell(v, rtl=lang in RTL_LANGS) for v in (lang, *lang_values(row, lang))))
576
585
  blocks.append(table(lines))
577
586
  return blocks
578
587
 
@@ -785,6 +794,11 @@ def main():
785
794
  cli.add_argument("--pdf", action="store_true", help="add a PDF to the outputs")
786
795
  cli.add_argument("--all", action="store_true", help="shorthand for --docx plus --pdf")
787
796
  cli.add_argument("--print-upload", action="store_true", help="show the publish command and a hand-rolled curl version of it")
797
+ cli.add_argument(
798
+ "--langs", default="all",
799
+ help="languages of the details tables, comma list in display order; 'all' (default) takes every language "
800
+ "in the mapping, en first then the rest sorted",
801
+ )
788
802
  opts = cli.parse_args()
789
803
 
790
804
  mapping, mapping_dir = read_mapping(opts.mapping)
@@ -795,7 +809,8 @@ def main():
795
809
  out_dir = os.path.abspath(opts.target_dir)
796
810
  os.makedirs(out_dir, exist_ok=True)
797
811
  shots = load_keyshots(mapping, mapping_dir, out_dir)
798
- doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir)
812
+ langs = None if opts.langs.strip() == "all" else parse_langs(opts.langs)
813
+ doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir, langs)
799
814
  doc.base = mapping_dir
800
815
 
801
816
  renderers = [("md", doc.md), ("confluence.xml", doc.xml), ("preview.html", doc.preview)]
@@ -17,6 +17,8 @@ import urllib.parse
17
17
  import urllib.request
18
18
  from datetime import datetime, timezone
19
19
 
20
+ from _shared import add_token_flag, reject_token_flag
21
+
20
22
  API = "https://api.figma.com/v1"
21
23
  BATCH = 4
22
24
  SEP = "[:\\-\u2013]"
@@ -117,9 +119,7 @@ def keychain_secret(service):
117
119
  return secret if lookup.returncode == 0 and secret else None
118
120
 
119
121
 
120
- def figma_token(explicit):
121
- if explicit:
122
- return explicit
122
+ def figma_token():
123
123
  from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
124
124
  return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
125
125
 
@@ -210,11 +210,11 @@ def pick_source(opts, cli, file_key, nodes):
210
210
  if opts.source == "rest":
211
211
  if not have_target:
212
212
  cli.error("--source rest needs a file key and node-ids (--file/--nodes or --mapping)")
213
- token = figma_token(opts.token)
213
+ token = figma_token()
214
214
  if not token:
215
- sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, keep it in the keychain under FIGMA_ACCESS_TOKEN, or use --token.")
215
+ sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, or keep it in the keychain under FIGMA_ACCESS_TOKEN.")
216
216
  return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
217
- token = figma_token(opts.token) if have_target else None
217
+ token = figma_token() if have_target else None
218
218
  if have_target and token:
219
219
  try:
220
220
  return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
@@ -236,10 +236,11 @@ def main():
236
236
  cli.add_argument("--from-mcp", help="JSON dump of annotations taken through MCP")
237
237
  cli.add_argument("--local", help="screen folder of a design export that contains tree.json")
238
238
  cli.add_argument("--source", default="auto", choices=["auto", "rest", "mcp", "local"])
239
- cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
239
+ add_token_flag(cli)
240
240
  cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
241
241
  cli.add_argument("--out", help="file to write; stdout when omitted")
242
242
  opts = cli.parse_args()
243
+ reject_token_flag(cli, opts, "export FIGMA_ACCESS_TOKEN (or FIGMA_TOKEN), or keep it in the macOS keychain under FIGMA_ACCESS_TOKEN")
243
244
 
244
245
  file_key = opts.file
245
246
  nodes = [piece for piece in (opts.nodes or "").split(",") if piece.strip()]
@@ -11,14 +11,13 @@ import os
11
11
  import sys
12
12
  import urllib.request
13
13
 
14
- from _shared import LANGS as ALL_LANGS
14
+ from _shared import parse_langs
15
15
 
16
16
 
17
-
18
- def parse_langs(value):
19
- if value.strip() == "all":
20
- return list(ALL_LANGS)
21
- return [part.strip() for part in value.split(",") if part.strip()]
17
+ def snapshot_langs(snapshot_dir):
18
+ if not os.path.isdir(snapshot_dir):
19
+ return []
20
+ return [name[:-5] for name in os.listdir(snapshot_dir) if name.endswith(".json")]
22
21
 
23
22
 
24
23
  def parse_pairs(pairs, flag):
@@ -81,7 +80,7 @@ def main():
81
80
  cli.add_argument("--endpoint", required=True, help="URL template containing {lang} and, if needed, {env}")
82
81
  cli.add_argument("--header", action="append", help="additional request header as K=V; may be repeated")
83
82
  cli.add_argument("--headers-file", help="request headers as a JSON object, so secrets stay out of argv")
84
- cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all'")
83
+ cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all' for every language already in the snapshot (en when it is empty)")
85
84
  cli.add_argument("--env", default="", help="text put in place of {env}")
86
85
  cli.add_argument("--status-field", default="statusCode", help="name of the response field that holds a status code")
87
86
  cli.add_argument("--fail-status", action="append", help="CODE=message pair that marks a failed response; may be repeated")
@@ -93,7 +92,7 @@ def main():
93
92
 
94
93
  headers = build_headers(opts)
95
94
  fail_status = parse_pairs(opts.fail_status, "--fail-status")
96
- langs = parse_langs(opts.langs)
95
+ langs = parse_langs(opts.langs, snapshot_langs(opts.snapshot_dir))
97
96
  os.makedirs(opts.snapshot_dir, exist_ok=True)
98
97
 
99
98
  total = 0
@@ -18,9 +18,14 @@ import tempfile
18
18
  import urllib.parse
19
19
  from xml.sax.saxutils import escape, quoteattr
20
20
 
21
+ from _shared import add_token_flag, reject_token_flag
22
+
21
23
  EMBED_EXTENSIONS = {".xlsx", ".xls", ".csv", ".docx", ".pdf"}
22
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:
@@ -111,7 +120,7 @@ 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")
116
125
  cli.add_argument("--embed-heading", default=EMBED_HEADING, help="heading above the embedded spreadsheet / document previews")
117
126
  cli.add_argument("--dry-run", action="store_true", help="report the planned actions without touching Confluence")
@@ -163,6 +172,10 @@ def upload(client, page_id, path):
163
172
  def main():
164
173
  cli = build_cli()
165
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
+ )
166
179
  check_inputs(cli, opts)
167
180
  title = opts.title or f"{opts.title_prefix} - {opts.screen}"
168
181
 
@@ -185,7 +198,7 @@ def main():
185
198
  print(json.dumps(plan, indent=2, ensure_ascii=False))
186
199
  return
187
200
 
188
- token = resolve_token(opts.token, opts.keychain_name)
201
+ token = resolve_token(opts.keychain_name)
189
202
  if not token:
190
203
  sys.exit(
191
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,7 +25,9 @@ import urllib.error
25
25
  import urllib.parse
26
26
  import urllib.request
27
27
 
28
- from _shared import slug
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"
29
31
 
30
32
  API = "https://api.figma.com/v1"
31
33
  SEP = "[:\\-\u2013]"
@@ -137,9 +139,7 @@ def keychain_secret(service):
137
139
  return secret if lookup.returncode == 0 and secret else None
138
140
 
139
141
 
140
- def figma_token(explicit):
141
- if explicit:
142
- return explicit
142
+ def figma_token():
143
143
  from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
144
144
  return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
145
145
 
@@ -468,7 +468,7 @@ def build_cli():
468
468
  cli.add_argument("--file", help="Figma file key; falls back to figmaFileKey in the mapping")
469
469
  cli.add_argument("--nodes", help="node-ids separated by commas, the screen frame first; falls back to figmaNodes")
470
470
  cli.add_argument("--scale", default=2, type=int)
471
- 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)
472
472
  cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
473
473
  cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
474
474
  cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
@@ -479,6 +479,7 @@ def build_cli():
479
479
  def main():
480
480
  cli = build_cli()
481
481
  opts = cli.parse_args()
482
+ reject_token_flag(cli, opts, FIGMA_TOKEN_HINT)
482
483
  labels = LABELS[opts.ui_lang]
483
484
 
484
485
  with open(opts.mapping, encoding="utf-8") as stream:
@@ -499,9 +500,9 @@ def main():
499
500
  nodes = split_nodes(opts.nodes, mapping)
500
501
  if not (file_key and nodes):
501
502
  cli.error("need a Figma file key and frame node-id (flags or mapping), or --spec")
502
- token = figma_token(opts.token)
503
+ token = figma_token()
503
504
  if not token:
504
- 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). "
505
506
  "Without one, measure the boxes on the screenshot and hand them over with --spec.")
506
507
  page = page_from_rest(file_key, canon_id(nodes[0]), opts.scale, token, opts.ca_file)
507
508
 
@@ -13,7 +13,7 @@ import plistlib
13
13
  import sys
14
14
  import urllib.request
15
15
 
16
- from _shared import LANGS as ALL_LANGS
16
+ from _shared import parse_langs
17
17
 
18
18
 
19
19
 
@@ -21,10 +21,17 @@ def split_csv(value):
21
21
  return [part.strip() for part in value.split(",") if part.strip()]
22
22
 
23
23
 
24
- def parse_langs(value):
25
- if value.strip() == "all":
26
- return list(ALL_LANGS)
27
- 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
28
35
 
29
36
 
30
37
  def parse_pairs(pairs, flag):
@@ -134,7 +141,7 @@ def from_live(args, keys, langs):
134
141
  def main():
135
142
  parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
136
143
  parser.add_argument("--keys", required=True, help="legacy keys separated by commas; leave off the backend prefix")
137
- 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")
138
145
  parser.add_argument("--snapshot-root", help="directory holding <lang>.json flat snapshots")
139
146
  parser.add_argument("--live", action="store_true", help="query the label endpoint instead of the snapshot")
140
147
  parser.add_argument("--endpoint", help="URL template with {lang} and optional {env}")
@@ -145,11 +152,11 @@ def main():
145
152
  parser.add_argument("--fail-status", action="append", help="CODE=message treated as a failure (repeatable)")
146
153
  parser.add_argument("--timeout", type=int, default=30)
147
154
  parser.add_argument("--plist-root", help="last resort: directory of bundled <lang>.lproj/language.plist")
148
- 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-")
149
156
  args = parser.parse_args()
150
157
 
151
158
  keys = split_csv(args.keys)
152
- langs = parse_langs(args.langs)
159
+ langs = parse_langs(args.langs, present_langs(args.snapshot_root, args.plist_root))
153
160
  if not (args.snapshot_root or args.plist_root or args.endpoint):
154
161
  sys.exit("no source given: pass --snapshot-root, --plist-root, or --live with --endpoint")
155
162
 
@@ -11,7 +11,7 @@ import json
11
11
  import os
12
12
  import sys
13
13
 
14
- from _shared import LANGS as ALL_LANGS, normalize_key
14
+ from _shared import normalize_key, parse_langs
15
15
 
16
16
 
17
17
  DOMAIN_DIRS = {
@@ -20,12 +20,6 @@ DOMAIN_DIRS = {
20
20
  }
21
21
 
22
22
 
23
- def parse_langs(value):
24
- if value.strip() == "all":
25
- return list(ALL_LANGS)
26
- return [part.strip() for part in value.split(",") if part.strip()]
27
-
28
-
29
23
  def read_keys(opts):
30
24
  raw = [piece.strip() for piece in opts.keys.split(",")] if opts.keys else []
31
25
  if opts.keys_file:
@@ -81,6 +75,20 @@ def read_suggested(path, langs):
81
75
  return {lang: value_of(lang) for lang in langs}
82
76
 
83
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
+
84
92
  def from_catalog(catalog, key, langs):
85
93
  for candidate in dict.fromkeys((key, normalize_key(key))):
86
94
  entry = catalog.get(candidate)
@@ -101,7 +109,7 @@ def main():
101
109
  cli.add_argument("--domain", default="localization", choices=sorted(DOMAIN_DIRS))
102
110
  cli.add_argument("--catalog", help="string catalog the app ships (.xcstrings or flat JSON), consulted when a key has no Suggested file")
103
111
  cli.add_argument("--report-source", action="store_true", help="return {source, values} per key instead of the bare values")
104
- 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")
105
113
  opts = cli.parse_args()
106
114
 
107
115
  keys = read_keys(opts)
@@ -113,8 +121,8 @@ def main():
113
121
  if not os.path.isdir(directory):
114
122
  cli.error(f"no Suggested folder at {directory} (check --resources-root / --suggested-dir / --domain)")
115
123
 
116
- langs = parse_langs(opts.langs)
117
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 ())
118
126
 
119
127
  result, via_catalog, unresolved = {}, [], []
120
128
  for key in keys: