@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.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 (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +10 -6
  3. package/README.tr.md +143 -0
  4. package/docs/architecture.md +23 -8
  5. package/docs/ecosystem.md +237 -0
  6. package/install/_plugin-skills.mjs +16 -2
  7. package/install/codex.mjs +9 -4
  8. package/install/templates/copilot-instructions.md +12 -9
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  13. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  15. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  16. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  17. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  18. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  19. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  20. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  21. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  22. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  23. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  24. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/extract-conventions.sh +44 -15
  28. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  29. package/pipeline/lib/fetch-fortify.sh +23 -8
  30. package/pipeline/lib/figma-screenshot.sh +11 -1
  31. package/pipeline/lib/issue-fetcher.sh +76 -9
  32. package/pipeline/lib/md2confluence-v3.py +16 -2
  33. package/pipeline/lib/plan-todos.sh +5 -2
  34. package/pipeline/lib/post-pr-review.sh +8 -6
  35. package/pipeline/lib/shadow-git.sh +50 -9
  36. package/pipeline/lib/submodule-detector.sh +8 -1
  37. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  38. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  39. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  40. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  41. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  42. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  43. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  44. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  45. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  46. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  47. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  48. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  49. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  50. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  51. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  52. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  53. package/pipeline/multi-agent-refs/phases.md +6 -0
  54. package/pipeline/multi-agent-refs/rules.md +2 -0
  55. package/pipeline/schemas/prefs.schema.json +1 -1
  56. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  57. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  58. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  59. package/pipeline/scripts/diff-explain.mjs +41 -3
  60. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  61. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  62. package/pipeline/scripts/learning-curve.mjs +8 -2
  63. package/pipeline/scripts/output-quality-check.sh +15 -4
  64. package/pipeline/scripts/phase-tracker.sh +21 -8
  65. package/pipeline/scripts/pre-commit-check.sh +69 -22
  66. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  67. package/pipeline/scripts/render-cost-summary.sh +42 -22
  68. package/pipeline/scripts/render-work-summary.sh +47 -13
  69. package/pipeline/scripts/review-scope.mjs +1 -1
  70. package/pipeline/scripts/run-aggregator.mjs +38 -14
  71. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  72. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  73. package/pipeline/scripts/uninstall.mjs +39 -4
  74. package/pipeline/scripts/update-issue-progress.sh +12 -16
  75. package/pipeline/scripts/worktree-finalize.sh +23 -2
  76. package/pipeline/skills/.skills-index.json +11 -2
  77. package/pipeline/skills/shared/README.md +1 -1
  78. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  79. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  80. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  81. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  82. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  83. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  84. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  85. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  86. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  87. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  88. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  89. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  90. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  91. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  92. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  93. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  94. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  95. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  96. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  97. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  98. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  99. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  100. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  101. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  102. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  103. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  104. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  105. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  106. package/pipeline/skills/skills-index.md +3 -2
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Refresh the LEGACY label snapshot: fetch a legacy label map for every language
3
+ from your project's label endpoint and write it into the resources snapshot, so the
4
+ mapper resolves legacy values offline instead of a network round-trip per run. This
5
+ is the ONLY step that hits the network - run it manually / in CI to refresh, the same
6
+ way you refresh a design export.
7
+
8
+ Writes <out>/<lang>.json = flat {<key>: value} (keys verbatim, any prefix kept).
9
+
10
+ The endpoint is entirely project-specific, so nothing about it is baked in - you
11
+ supply the URL template and the headers it needs:
12
+
13
+ fetch-legacy-labels.py --out resources/Localization/Legacy --langs all \\
14
+ --endpoint 'https://labels.example.internal/{env}/labels/{lang}' \\
15
+ --headers-file .secrets/label-headers.json
16
+
17
+ Placeholders available in --endpoint: {lang} and {env}.
18
+
19
+ Prefer --headers-file (JSON object) over repeated --header for anything secret, so
20
+ tokens never land in shell history or CI logs. Response shape is discovered rather
21
+ than assumed: the largest flat {str: str} map anywhere in the JSON wins, which covers
22
+ both a bare map and one nested under an envelope.
23
+ """
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ import urllib.request
29
+
30
+ LANGS_ALL = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
31
+
32
+
33
+ def largest_str_map(obj):
34
+ best, stack = {}, [obj]
35
+ while stack:
36
+ cur = stack.pop()
37
+ if isinstance(cur, dict):
38
+ strs = {k: v for k, v in cur.items() if isinstance(v, str)}
39
+ if len(strs) > len(best):
40
+ best = strs
41
+ stack.extend(cur.values())
42
+ elif isinstance(cur, list):
43
+ stack.extend(cur)
44
+ return best
45
+
46
+
47
+ def parse_headers(a):
48
+ headers = {"Accept": "application/json"}
49
+ if a.headers_file:
50
+ with open(a.headers_file, encoding="utf-8") as fh:
51
+ loaded = json.load(fh)
52
+ if not isinstance(loaded, dict):
53
+ raise SystemExit(f"--headers-file must be a JSON object, got {type(loaded).__name__}")
54
+ headers.update({str(k): str(v) for k, v in loaded.items()})
55
+ for pair in a.header or []:
56
+ if "=" not in pair:
57
+ raise SystemExit(f"--header expects K=V, got {pair!r}")
58
+ k, v = pair.split("=", 1)
59
+ headers[k.strip()] = v.strip()
60
+ return headers
61
+
62
+
63
+ def parse_fail_statuses(values):
64
+ out = {}
65
+ for pair in values or []:
66
+ if "=" not in pair:
67
+ raise SystemExit(f"--fail-status expects CODE=message, got {pair!r}")
68
+ code, message = pair.split("=", 1)
69
+ out[code.strip()] = message.strip()
70
+ return out
71
+
72
+
73
+ def fetch(lang, headers, fail_statuses, a):
74
+ url = a.endpoint.format(env=a.env, lang=lang)
75
+ per_lang = dict(headers)
76
+ per_lang.setdefault("Accept-Language", lang)
77
+ req = urllib.request.Request(url, headers=per_lang)
78
+ with urllib.request.urlopen(req, timeout=a.timeout) as r:
79
+ data = json.loads(r.read().decode("utf-8"))
80
+ if isinstance(data, dict) and a.status_field in data:
81
+ code = str(data.get(a.status_field))
82
+ if code in fail_statuses:
83
+ raise RuntimeError(f"{url}: {fail_statuses[code]} (status {code})")
84
+ m = largest_str_map(data)
85
+ if not m:
86
+ raise RuntimeError(f"no label map found in the response for '{lang}' ({url})")
87
+ return m
88
+
89
+
90
+ def main():
91
+ ap = argparse.ArgumentParser(description=__doc__,
92
+ formatter_class=argparse.RawDescriptionHelpFormatter)
93
+ ap.add_argument("--out", required=True, help="snapshot dir, e.g. resources/Localization/Legacy")
94
+ ap.add_argument("--endpoint", required=True,
95
+ help="URL template for the legacy label map; may contain {lang} and {env}")
96
+ ap.add_argument("--header", action="append",
97
+ help="extra request header as K=V (repeatable); avoid for secrets")
98
+ ap.add_argument("--headers-file", help="JSON object of request headers - use this for tokens")
99
+ ap.add_argument("--langs", default="all", help="'all' or a comma-separated list")
100
+ ap.add_argument("--env", default="", help="value substituted for {env} in --endpoint")
101
+ ap.add_argument("--status-field", default="statusCode",
102
+ help="response field carrying a provider status code")
103
+ ap.add_argument("--fail-status", action="append",
104
+ help="CODE=message for a provider error code that must abort (repeatable)")
105
+ ap.add_argument("--timeout", type=int, default=40)
106
+ a = ap.parse_args()
107
+
108
+ if "{lang}" not in a.endpoint:
109
+ print("warning: --endpoint has no {lang} placeholder; every language will hit the same URL",
110
+ file=sys.stderr)
111
+
112
+ headers = parse_headers(a)
113
+ fail_statuses = parse_fail_statuses(a.fail_status)
114
+ langs = LANGS_ALL if a.langs.strip() == "all" else [l.strip() for l in a.langs.split(",") if l.strip()]
115
+ os.makedirs(a.out, exist_ok=True)
116
+ total = 0
117
+ for lang in langs:
118
+ m = fetch(lang, headers, fail_statuses, a)
119
+ path = os.path.join(a.out, f"{lang}.json")
120
+ with open(path, "w", encoding="utf-8") as fh:
121
+ json.dump(m, fh, ensure_ascii=False, indent=0, sort_keys=True)
122
+ fh.write("\n")
123
+ total += len(m)
124
+ print(f" {lang}: {len(m)} labels → {path}")
125
+ print(f"legacy snapshot refreshed: {len(langs)} languages, ~{total // max(len(langs), 1)} keys each")
126
+ print("→ commit it like a design-export refresh")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env python3
2
+ """Publish (or UPDATE) one screen's localization map to Confluence - live, idempotent.
3
+
4
+ Targets a Confluence **Server/Data Center** instance with a **Bearer Personal Access
5
+ Token** (Server/DC auth - NOT Atlassian Cloud `user:token`). Nothing about your wiki is
6
+ baked in: pass `--base-url`, `--space` and `--parent`, or set `CONFLUENCE_BASE_URL`,
7
+ `CONFLUENCE_SPACE` and `CONFLUENCE_PARENT` once in the environment.
8
+
9
+ Idempotent by title under a parent page:
10
+ * search `space=<SPACE> and title="<title>"` (CQL);
11
+ * **exists → PUT** `/rest/api/content/<id>` with `version.number+1` → updates the
12
+ SAME page in place (re-running the mapper on a screen refreshes its page, never
13
+ duplicates it);
14
+ * **absent → POST** `/rest/api/content` under `--parent`.
15
+ Then attaches `screenshot.png` + the overlay PNG + (via `--keyshots <manifest>`) every
16
+ per-key screenpiece PNG from render-key-shots.py (the `<ac:image ri:filename=...>`
17
+ references in the body resolve to these), creating or updating each by filename.
18
+ `--attach <file>` (repeatable) attaches any additional document - e.g. the content
19
+ team's `<slug>.localization.xlsx` (build-spreadsheet.py) - and, for office/spreadsheet
20
+ files, appends a `view-file` macro section so it also renders inline on the page.
21
+
22
+ HTTP goes through **curl** on purpose: curl uses the system trust store, so it works
23
+ behind a corporate TLS proxy where Python's `urllib` fails cert verification. The token
24
+ is read from the keychain (item `CONFLUENCE_API_TOKEN`) and passed to curl via a
25
+ `chmod 600` `-K` config file - never on the argv / `ps`.
26
+
27
+ The skill asks the user for the target (space / parent / formats) and passes them here.
28
+ Read-only against the local artifacts; the only writes are to Confluence.
29
+
30
+ Examples:
31
+ python3 publish-confluence.py --xml out/<slug>.confluence.xml --screen "<name>" \
32
+ --base-url https://wiki.example.com --space ENG --parent 12345 \
33
+ --screenshot out/screenshot.png --overlay out/<slug>.overlay.png
34
+ python3 publish-confluence.py --xml ... --title "Custom Title" # base/space/parent from env
35
+ python3 publish-confluence.py --xml ... --screen "<name>" --dry-run
36
+ """
37
+ import argparse
38
+ import json
39
+ import os
40
+ import subprocess
41
+ import sys
42
+ import tempfile
43
+ import urllib.parse
44
+
45
+ DEFAULT_BASE = os.environ.get("CONFLUENCE_BASE_URL", "")
46
+ DEFAULT_SPACE = os.environ.get("CONFLUENCE_SPACE", "")
47
+ DEFAULT_PARENT = os.environ.get("CONFLUENCE_PARENT", "")
48
+ KEYCHAIN_ITEM = "CONFLUENCE_API_TOKEN"
49
+
50
+
51
+ def get_token(args):
52
+ if args.token:
53
+ return args.token
54
+ v = os.environ.get("CONFLUENCE_API_TOKEN")
55
+ if v:
56
+ return v.strip()
57
+ try:
58
+ r = subprocess.run(
59
+ ["security", "find-generic-password", "-s", args.keychain_name, "-w"],
60
+ capture_output=True, text=True)
61
+ if r.returncode == 0 and r.stdout.strip():
62
+ return r.stdout.strip()
63
+ except Exception:
64
+ pass
65
+ sys.exit(f"No Confluence token - add keychain item '{args.keychain_name}', set "
66
+ "CONFLUENCE_API_TOKEN, or pass --token.")
67
+
68
+
69
+ class Cf:
70
+ """Thin curl-backed Confluence Server REST client (Bearer auth via -K config)."""
71
+
72
+ def __init__(self, base, token):
73
+ self.base = base.rstrip("/")
74
+ self._cfg = tempfile.NamedTemporaryFile("w", suffix=".curlrc", delete=False)
75
+ os.chmod(self._cfg.name, 0o600)
76
+ self._cfg.write(f'header = "Authorization: Bearer {token}"\n')
77
+ self._cfg.close()
78
+
79
+ def close(self):
80
+ try:
81
+ os.unlink(self._cfg.name)
82
+ except OSError:
83
+ pass
84
+
85
+ def _run(self, args):
86
+ r = subprocess.run(["curl", "-sS", "-K", self._cfg.name, *args],
87
+ capture_output=True, text=True)
88
+ if r.returncode != 0:
89
+ sys.exit(f"curl failed: {r.stderr.strip()}")
90
+ return r.stdout
91
+
92
+ def _json(self, args):
93
+ out = self._run(args)
94
+ try:
95
+ data = json.loads(out)
96
+ except json.JSONDecodeError:
97
+ sys.exit(f"Confluence returned non-JSON (auth/endpoint?):\n{out[:400]}")
98
+ if isinstance(data, dict) and data.get("statusCode") and data.get("message"):
99
+ sys.exit(f"Confluence error {data['statusCode']}: {data['message']}")
100
+ return data
101
+
102
+ def find_by_title(self, space, title):
103
+ # DB-backed content query (NOT /content/search - CQL hits the async search
104
+ # index, which lags seconds behind a just-created page and breaks idempotency).
105
+ qs = urllib.parse.urlencode({"spaceKey": space, "title": title, "type": "page",
106
+ "expand": "version", "limit": "5"})
107
+ data = self._json([f"{self.base}/rest/api/content?{qs}"])
108
+ results = data.get("results") or []
109
+ return results[0] if results else None
110
+
111
+ def create(self, space, parent, title, storage):
112
+ body = {"type": "page", "title": title, "space": {"key": space},
113
+ "ancestors": [{"id": str(parent)}],
114
+ "body": {"storage": {"value": storage, "representation": "storage"}}}
115
+ return self._post_json("POST", f"{self.base}/rest/api/content", body)
116
+
117
+ def update(self, page_id, title, storage, new_version, space):
118
+ body = {"id": str(page_id), "type": "page", "title": title,
119
+ "space": {"key": space},
120
+ "version": {"number": new_version},
121
+ "body": {"storage": {"value": storage, "representation": "storage"}}}
122
+ return self._post_json("PUT", f"{self.base}/rest/api/content/{page_id}", body)
123
+
124
+ def _post_json(self, method, url, body):
125
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as bf:
126
+ json.dump(body, bf, ensure_ascii=False)
127
+ bpath = bf.name
128
+ try:
129
+ return self._json(["-X", method, "-H", "Content-Type: application/json",
130
+ "--data", f"@{bpath}", url])
131
+ finally:
132
+ os.unlink(bpath)
133
+
134
+ def existing_attachment(self, page_id, filename):
135
+ q = urllib.parse.quote(filename)
136
+ data = self._json([f"{self.base}/rest/api/content/{page_id}/child/attachment?filename={q}&limit=1"])
137
+ results = data.get("results") or []
138
+ return results[0] if results else None
139
+
140
+ def attach(self, page_id, path):
141
+ fn = os.path.basename(path)
142
+ existing = self.existing_attachment(page_id, fn)
143
+ if existing:
144
+ url = f"{self.base}/rest/api/content/{page_id}/child/attachment/{existing['id']}/data"
145
+ else:
146
+ url = f"{self.base}/rest/api/content/{page_id}/child/attachment"
147
+ return self._json(["-H", "X-Atlassian-Token: nocheck",
148
+ "-F", f"file=@{path}", "-X", "POST", url])
149
+
150
+
151
+ EMBED_EXTS = (".xlsx", ".xls", ".csv", ".docx", ".pdf")
152
+
153
+
154
+ def attachment_section(paths):
155
+ """Storage-format section embedding a `view-file` macro for each office/spreadsheet
156
+ attachment, so the file renders inline on the page (not only in the drawer). Returns
157
+ "" when no path is an embeddable document."""
158
+ docs = [p for p in paths if os.path.splitext(p)[1].lower() in EMBED_EXTS]
159
+ if not docs:
160
+ return ""
161
+ macros = "".join(
162
+ '<p><ac:structured-macro ac:name="view-file">'
163
+ '<ac:parameter ac:name="name">'
164
+ f'<ri:attachment ri:filename="{os.path.basename(p)}"/>'
165
+ '</ac:parameter></ac:structured-macro></p>'
166
+ for p in docs
167
+ )
168
+ return "<h2>CMS Aktarım Dosyası</h2>" + macros
169
+
170
+
171
+ def main():
172
+ ap = argparse.ArgumentParser(description=__doc__,
173
+ formatter_class=argparse.RawDescriptionHelpFormatter)
174
+ ap.add_argument("--xml", required=True, help="<slug>.confluence.xml (storage body)")
175
+ ap.add_argument("--screen", help="screen name (used in the default title)")
176
+ ap.add_argument("--title", help="page title (default: '<title-prefix> - <screen>')")
177
+ ap.add_argument("--title-prefix", default=os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization"),
178
+ help="prefix for the default title; set it to your wiki's wording "
179
+ "(env LOCALIZATION_PAGE_PREFIX)")
180
+ ap.add_argument("--screenshot", help="screenshot PNG to attach")
181
+ ap.add_argument("--overlay", help="overlay PNG to attach")
182
+ ap.add_argument("--keyshots", metavar="MANIFEST",
183
+ help="<slug>.keyshots.manifest.json (render-key-shots.py) - attaches every "
184
+ "per-key screenpiece PNG it lists, so the table's Ekran Görüntüsü "
185
+ "cells (<ri:attachment> refs) render on the page")
186
+ ap.add_argument("--attach", action="append", default=[], metavar="FILE",
187
+ help="extra file to attach (repeatable); e.g. <slug>.localization.xlsx. "
188
+ "Office/spreadsheet files also get a view-file macro on the page.")
189
+ ap.add_argument("--base", "--base-url", dest="base", default=DEFAULT_BASE,
190
+ help="Confluence base URL, e.g. https://wiki.example.com (env CONFLUENCE_BASE_URL)")
191
+ ap.add_argument("--space", default=DEFAULT_SPACE, help="space key (env CONFLUENCE_SPACE)")
192
+ ap.add_argument("--parent", default=DEFAULT_PARENT,
193
+ help="parent pageId the screen pages hang under (env CONFLUENCE_PARENT)")
194
+ ap.add_argument("--token", help="Confluence PAT (else keychain / env)")
195
+ ap.add_argument("--keychain-name", default=KEYCHAIN_ITEM)
196
+ ap.add_argument("--dry-run", action="store_true", help="print the plan, write nothing")
197
+ args = ap.parse_args()
198
+
199
+ title = args.title or (f"{args.title_prefix} - {args.screen}" if args.screen else None)
200
+ if not title:
201
+ ap.error("supply --title or --screen")
202
+ missing = [flag for flag, value in (("--base-url", args.base), ("--space", args.space),
203
+ ("--parent", args.parent)) if not value]
204
+ if missing and not args.dry_run:
205
+ ap.error("no Confluence target configured - pass " + ", ".join(missing) +
206
+ " (or set CONFLUENCE_BASE_URL / CONFLUENCE_SPACE / CONFLUENCE_PARENT)")
207
+ if not os.path.isfile(args.xml):
208
+ ap.error(f"--xml not found: {args.xml}")
209
+ storage = open(args.xml, encoding="utf-8").read()
210
+
211
+ keyshot_files = []
212
+ if args.keyshots:
213
+ if not os.path.isfile(args.keyshots):
214
+ ap.error(f"--keyshots manifest not found: {args.keyshots}")
215
+ with open(args.keyshots, encoding="utf-8") as fh:
216
+ man = json.load(fh)
217
+ base = os.path.dirname(os.path.abspath(args.keyshots))
218
+ keyshot_files = [os.path.join(base, s["file"]) for s in man.get("shots", [])
219
+ if s.get("file", "").endswith(".png")]
220
+
221
+ attachments = [p for p in (args.screenshot, args.overlay, *keyshot_files, *args.attach) if p]
222
+ for p in attachments:
223
+ if not os.path.isfile(p):
224
+ ap.error(f"attachment not found: {p}")
225
+
226
+ # Embed a view-file macro for the spreadsheet/office attachments so they render on the page.
227
+ storage += attachment_section(args.attach)
228
+
229
+ if args.dry_run:
230
+ print(json.dumps({
231
+ "base": args.base, "space": args.space, "parent": args.parent,
232
+ "title": title, "xmlBytes": len(storage),
233
+ "attachments": [os.path.basename(p) for p in attachments],
234
+ "action": "search-then-create-or-update (idempotent by title)",
235
+ }, ensure_ascii=False, indent=2))
236
+ return
237
+
238
+ cf = Cf(args.base, get_token(args))
239
+ try:
240
+ existing = cf.find_by_title(args.space, title)
241
+ if existing:
242
+ cur = int(existing.get("version", {}).get("number", 1))
243
+ page = cf.update(existing["id"], title, storage, cur + 1, args.space)
244
+ action = f"updated (v{cur}→v{cur + 1})"
245
+ else:
246
+ page = cf.create(args.space, args.parent, title, storage)
247
+ action = "created"
248
+ page_id = page["id"]
249
+
250
+ attached = []
251
+ for p in attachments:
252
+ cf.attach(page_id, p)
253
+ attached.append(os.path.basename(p))
254
+
255
+ webui = (page.get("_links") or {}).get("webui", "")
256
+ url = f"{args.base}{webui}" if webui else f"{args.base}/pages/viewpage.action?pageId={page_id}"
257
+ print(json.dumps({"action": action, "id": page_id, "title": title,
258
+ "attachments": attached, "url": url}, ensure_ascii=False, indent=2))
259
+ finally:
260
+ cf.close()
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()