@mmerterden/multi-agent-pipeline 12.1.1 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # fetch-figma-annotations.sh
4
+ #
5
+ # Tier-2 (Figma REST) fallback for /multi-agent:analysis annotation ingestion.
6
+ # Fetches Dev Mode annotations for one or more nodes and emits them as the
7
+ # authoritative copy for each node (the visible text layer is a placeholder).
8
+ #
9
+ # Tier 1 (Figma MCP get_design_context) is preferred and handled in the skill;
10
+ # this script is the no-MCP fallback. Generic: no project file keys, no
11
+ # language codes, and no corporate transport are baked in - the caller passes
12
+ # the file key, node ids, and the recognized language prefixes.
13
+ #
14
+ # Usage:
15
+ # fetch-figma-annotations.sh --file-key <key> --node-id <id>[,<id>...] \
16
+ # [--lang-prefixes TR,EN] [--batch 4]
17
+ # fetch-figma-annotations.sh --url <figma-url> [--lang-prefixes TR,EN]
18
+ #
19
+ # Token source: credential-store.sh get <logical-key>, where <logical-key> is
20
+ # read from prefs.global.keychainMapping.figma_pat. Falls back to the FIGMA_PAT
21
+ # environment variable. No literal keychain service name is embedded here.
22
+ #
23
+ # Label parsing (config-driven via --lang-prefixes, ordered):
24
+ # prefixed : lines like "TR: ...", "EN: ..." win regardless of order
25
+ # two-line : line 1 -> prefixes[0], line 2 -> prefixes[1]
26
+ # single : one line -> prefixes[0]
27
+ # empty : no label text
28
+ #
29
+ # Output (stdout, single JSON object):
30
+ # { "ok": true, "source": "figma-rest", "fileKey": "...", "fetchedAt": "...",
31
+ # "count": N, "withBase": M,
32
+ # "annotations": [ {nodeId, name, designText, raw, parsed:{<lang>:val}, mode} ] }
33
+ # On missing/rejected token: { "ok": false, "reason": "missing-token" } and exit 2.
34
+ #
35
+ # Exit codes: 0 success (incl. zero annotations), 1 usage, 2 auth,
36
+ # 4 node not found, 5 rate limited, 6 network/other fatal.
37
+
38
+ set -uo pipefail
39
+
40
+ PREFS="$HOME/.claude/multi-agent-preferences.json"
41
+ # shellcheck disable=SC1090,SC1091
42
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
43
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
44
+ || true
45
+ CRED_STORE="${CRED_STORE:-}"
46
+ FIGMA_API="https://api.figma.com/v1"
47
+ HTTP_TIMEOUT=60
48
+
49
+ FILE_KEY=""
50
+ NODE_IDS=""
51
+ URL=""
52
+ LANG_PREFIXES="TR,EN"
53
+ BATCH=4
54
+
55
+ log() { printf '%s\n' "$*" >&2; }
56
+ die() { local code="$1"; shift; log "fetch-figma-annotations: $*"; exit "$code"; }
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --file-key) FILE_KEY="${2:-}"; shift 2 ;;
61
+ --node-id) NODE_IDS="${2:-}"; shift 2 ;;
62
+ --url) URL="${2:-}"; shift 2 ;;
63
+ --lang-prefixes) LANG_PREFIXES="${2:-TR,EN}"; shift 2 ;;
64
+ --batch) BATCH="${2:-4}"; shift 2 ;;
65
+ -h|--help) grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'; exit 0 ;;
66
+ *) die 1 "unknown argument: $1" ;;
67
+ esac
68
+ done
69
+
70
+ # Parse fileKey + nodeId from a Figma URL when given.
71
+ if [ -n "$URL" ]; then
72
+ parsed_key=$(printf '%s' "$URL" | sed -nE 's#.*figma\.com/(design|file)/([A-Za-z0-9]+).*#\2#p')
73
+ parsed_node=$(printf '%s' "$URL" | sed -nE 's#.*[?&]node-id=([^&]+).*#\1#p' | sed 's/-/:/')
74
+ [ -z "$FILE_KEY" ] && FILE_KEY="$parsed_key"
75
+ [ -z "$NODE_IDS" ] && NODE_IDS="$parsed_node"
76
+ fi
77
+
78
+ [ -z "$FILE_KEY" ] && die 1 "missing --file-key (or a --url to parse it from)"
79
+ [ -z "$NODE_IDS" ] && die 1 "missing --node-id (or a --url to parse it from)"
80
+
81
+ # Resolve the Figma PAT (keychain via prefs mapping, then FIGMA_PAT env).
82
+ resolve_token() {
83
+ local token_key=""
84
+ if [ -f "$PREFS" ] && command -v python3 >/dev/null 2>&1; then
85
+ token_key=$(python3 -c "
86
+ import json
87
+ try:
88
+ p = json.load(open('$PREFS'))
89
+ print(p.get('global', {}).get('keychainMapping', {}).get('figma_pat') or '')
90
+ except Exception:
91
+ print('')
92
+ " 2>/dev/null || true)
93
+ fi
94
+ if [ -n "$token_key" ] && [ -n "$CRED_STORE" ] && [ -x "$CRED_STORE" ]; then
95
+ local tok
96
+ tok=$("$CRED_STORE" get "$token_key" 2>/dev/null || true)
97
+ if [ -n "$tok" ]; then printf '%s' "$tok"; return 0; fi
98
+ fi
99
+ if [ -n "${FIGMA_PAT:-}" ]; then printf '%s' "$FIGMA_PAT"; return 0; fi
100
+ return 1
101
+ }
102
+
103
+ FIGMA_TOKEN=$(resolve_token || true)
104
+ if [ -z "$FIGMA_TOKEN" ]; then
105
+ printf '{"ok":false,"reason":"missing-token","source":"figma-rest","fileKey":"%s"}\n' "$FILE_KEY"
106
+ die 2 "no Figma PAT (map prefs.global.keychainMapping.figma_pat or set FIGMA_PAT)"
107
+ fi
108
+
109
+ TMPDIR_A=$(mktemp -d "${TMPDIR:-/tmp}/figma-annot.XXXXXX")
110
+ trap 'rm -rf "$TMPDIR_A"' EXIT INT TERM
111
+
112
+ # curl one /nodes batch (token via -K config so it never lands in argv).
113
+ curl_nodes() {
114
+ local out="$1" ids="$2" code
115
+ code=$(curl -sS -o "$out" -w '%{http_code}' \
116
+ --max-time "$HTTP_TIMEOUT" --connect-timeout 5 \
117
+ -K <(printf 'header = "X-Figma-Token: %s"\n' "$FIGMA_TOKEN") \
118
+ "$FIGMA_API/files/$FILE_KEY/nodes?ids=$ids" || echo "000")
119
+ printf '%s' "$code"
120
+ }
121
+
122
+ # Split comma ids into batches of $BATCH, fetch each into TMPDIR_A/part-N.json.
123
+ OLD_IFS="$IFS"; IFS=','; set -- $NODE_IDS; IFS="$OLD_IFS"
124
+ part=0; batch_ids=""; n=0
125
+ flush_batch() {
126
+ [ -z "$batch_ids" ] && return 0
127
+ part=$((part + 1))
128
+ local code
129
+ code=$(curl_nodes "$TMPDIR_A/part-$part.json" "$batch_ids")
130
+ case "$code" in
131
+ 2*) : ;;
132
+ 401|403) die 2 "auth rejected (HTTP $code)" ;;
133
+ 404) die 4 "node(s) not found (HTTP 404)" ;;
134
+ 429) die 5 "rate limited (HTTP 429)" ;;
135
+ *) die 6 "figma call failed (HTTP $code)" ;;
136
+ esac
137
+ batch_ids=""; n=0
138
+ }
139
+ for id in "$@"; do
140
+ [ -z "$id" ] && continue
141
+ if [ -z "$batch_ids" ]; then batch_ids="$id"; else batch_ids="$batch_ids,$id"; fi
142
+ n=$((n + 1))
143
+ [ "$n" -ge "$BATCH" ] && flush_batch
144
+ done
145
+ flush_batch
146
+
147
+ # Walk all node docs, collect + parse annotations. Python only parses local
148
+ # response files; the token is never passed to it.
149
+ LANG_PREFIXES="$LANG_PREFIXES" FILE_KEY="$FILE_KEY" python3 - "$TMPDIR_A" <<'PY'
150
+ import json, os, sys, glob, datetime
151
+
152
+ parts_dir = sys.argv[1]
153
+ prefixes = [p.strip() for p in os.environ.get("LANG_PREFIXES", "TR,EN").split(",") if p.strip()]
154
+ base = (prefixes[0].lower() if prefixes else "tr")
155
+
156
+ def parse_label(raw):
157
+ if not raw or not raw.strip():
158
+ return {}, "empty"
159
+ lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
160
+ parsed = {}
161
+ # prefixed: "TR: ...", "EN: ..."
162
+ prefixed = False
163
+ for ln in lines:
164
+ for p in prefixes:
165
+ lp = p.lower()
166
+ low = ln.lower()
167
+ if low.startswith(lp + ":") or low.startswith(lp + " :"):
168
+ parsed[lp] = ln.split(":", 1)[1].strip()
169
+ prefixed = True
170
+ if prefixed:
171
+ return parsed, "prefixed"
172
+ if len(lines) >= 2 and len(prefixes) >= 2:
173
+ return {prefixes[0].lower(): lines[0], prefixes[1].lower(): lines[1]}, "two-line"
174
+ if len(lines) == 1 and prefixes:
175
+ return {prefixes[0].lower(): lines[0]}, "single"
176
+ # fallback: join everything under the base language
177
+ return {base: " ".join(lines)}, "single"
178
+
179
+ annotations = []
180
+
181
+ def walk(node):
182
+ if not isinstance(node, dict):
183
+ return
184
+ anns = node.get("annotations")
185
+ if isinstance(anns, list) and anns:
186
+ raw_parts = []
187
+ for a in anns:
188
+ if not isinstance(a, dict):
189
+ continue
190
+ lbl = a.get("labelMarkdown") or a.get("label") or ""
191
+ if lbl:
192
+ raw_parts.append(lbl)
193
+ raw = "\n".join(raw_parts)
194
+ parsed, mode = parse_label(raw)
195
+ design_text = node.get("characters")
196
+ annotations.append({
197
+ "nodeId": node.get("id"),
198
+ "name": node.get("name"),
199
+ "designText": design_text if isinstance(design_text, str) else None,
200
+ "raw": raw or None,
201
+ "parsed": parsed,
202
+ "mode": mode,
203
+ })
204
+ for child in node.get("children", []) or []:
205
+ walk(child)
206
+
207
+ for pf in sorted(glob.glob(os.path.join(parts_dir, "part-*.json"))):
208
+ try:
209
+ doc = json.load(open(pf))
210
+ except Exception:
211
+ continue
212
+ nodes = doc.get("nodes", {})
213
+ if isinstance(nodes, dict):
214
+ for _, entry in nodes.items():
215
+ if isinstance(entry, dict) and isinstance(entry.get("document"), dict):
216
+ walk(entry["document"])
217
+
218
+ with_base = sum(1 for a in annotations if a["parsed"].get(base))
219
+ # Flag base-present-but-a-target-missing to stderr (kick back to content team).
220
+ for a in annotations:
221
+ if a["parsed"].get(base):
222
+ missing = [p.lower() for p in prefixes if not a["parsed"].get(p.lower())]
223
+ if missing:
224
+ sys.stderr.write("WARN: node %s has %s but missing %s\n" % (
225
+ a.get("nodeId"), base, ",".join(missing)))
226
+
227
+ out = {
228
+ "ok": True,
229
+ "source": "figma-rest",
230
+ "fileKey": os.environ.get("FILE_KEY"),
231
+ "fetchedAt": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
232
+ "count": len(annotations),
233
+ "withBase": with_base,
234
+ "annotations": annotations,
235
+ }
236
+ print(json.dumps(out, ensure_ascii=False, indent=2))
237
+ PY
@@ -0,0 +1,233 @@
1
+ #!/bin/bash
2
+ #
3
+ # fetch-graylog.sh
4
+ # Query a Graylog instance for the log messages tied to a transaction or
5
+ # conversation id and emit a normalized JSON view the pipeline can attach as
6
+ # analysis context. A log fetch must never block a run: on any network failure
7
+ # (VPN down, host unreachable) the fetcher degrades to an empty result and
8
+ # exits 0.
9
+ #
10
+ # Input forms (at least one id is required):
11
+ # ./fetch-graylog.sh --trx <transaction-id>
12
+ # ./fetch-graylog.sh --conv <conversation-id>
13
+ # ./fetch-graylog.sh --trx <id> --conv <id>
14
+ #
15
+ # Required configuration:
16
+ # prefs.global.hosts.graylog - Graylog host (e.g. logs.example.com)
17
+ # prefs.global.keychainMapping.graylog - keychain key holding the API token
18
+ #
19
+ # Optional env:
20
+ # GRAYLOG_TIMEOUT_SECONDS default 25
21
+ # GRAYLOG_HOST_OVERRIDE forces the host (used by tests and CI dry-runs)
22
+ # GRAYLOG_RANGE_SECONDS relative search window, default 86400 (24h)
23
+ # GRAYLOG_LIMIT max messages to return, default 200
24
+ #
25
+ # Auth: Graylog personal access tokens authenticate as HTTP Basic with the
26
+ # token as the username and the literal string "token" as the password. The
27
+ # credential travels through a curl config via process substitution so it
28
+ # never appears in argv (argv is visible to ps).
29
+ #
30
+ # Output (stdout, single JSON object):
31
+ # {
32
+ # "fetchedAt": "<ISO8601>",
33
+ # "source": { "host": "<host>", "transactionId": "..."|null,
34
+ # "conversationId": "..."|null, "query": "..." },
35
+ # "totalResults": <n>,
36
+ # "messages": [
37
+ # { "timestamp": "...", "source": "...", "level": <n>|null,
38
+ # "message": "...", "fields": { ... } }
39
+ # ],
40
+ # "degraded": <bool>, "degradeReason": "..."|null
41
+ # }
42
+ #
43
+ # Exit codes:
44
+ # 0 success, or degraded-empty on a network failure (never blocks a run)
45
+ # 2 missing/expired credential (orchestrator handles the Save Flow)
46
+ # 3 auth rejected on a reachable host (4xx)
47
+ # 4 bad usage
48
+ # 6 host not configured (prefs.global.hosts.graylog is empty)
49
+
50
+ set -eu
51
+
52
+ TRX_ID=""
53
+ CONV_ID=""
54
+ TIMEOUT="${GRAYLOG_TIMEOUT_SECONDS:-25}"
55
+ RANGE="${GRAYLOG_RANGE_SECONDS:-86400}"
56
+ LIMIT="${GRAYLOG_LIMIT:-200}"
57
+
58
+ while [ $# -gt 0 ]; do
59
+ case "$1" in
60
+ --trx) TRX_ID="$2"; shift 2 ;;
61
+ --conv) CONV_ID="$2"; shift 2 ;;
62
+ -h|--help)
63
+ echo "usage: $0 --trx <transaction-id> | --conv <conversation-id>" >&2
64
+ exit 4 ;;
65
+ *)
66
+ echo "ERR: unexpected arg $1" >&2; exit 4 ;;
67
+ esac
68
+ done
69
+
70
+ # Host comes from prefs (private config); skill templates only show {GRAYLOG_HOST}.
71
+ PREFS="$HOME/.claude/multi-agent-preferences.json"
72
+ HOST="${GRAYLOG_HOST_OVERRIDE:-}"
73
+ if [ -z "$HOST" ] && [ -f "$PREFS" ]; then
74
+ HOST=$(python3 -c "
75
+ import json
76
+ try:
77
+ p = json.load(open('$PREFS'))
78
+ print(p.get('global', {}).get('hosts', {}).get('graylog') or '')
79
+ except Exception:
80
+ print('')
81
+ ")
82
+ fi
83
+ if [ -z "$HOST" ]; then
84
+ printf '%s\n' '{"status":"blocked","reason":"host-not-configured","service":"graylog","expected_pref":"global.hosts.graylog"}' >&2
85
+ exit 6
86
+ fi
87
+ HOST=${HOST%/} # strip trailing slash if any
88
+
89
+ if [ -z "$TRX_ID" ] && [ -z "$CONV_ID" ]; then
90
+ echo "ERR: no id (pass --trx <transaction-id> and/or --conv <conversation-id>)" >&2
91
+ exit 4
92
+ fi
93
+
94
+ # Build the Graylog query from whichever ids were supplied.
95
+ QUERY=""
96
+ if [ -n "$TRX_ID" ]; then
97
+ QUERY="transactionId:\"$TRX_ID\""
98
+ fi
99
+ if [ -n "$CONV_ID" ]; then
100
+ if [ -n "$QUERY" ]; then
101
+ QUERY="$QUERY OR conversationId:\"$CONV_ID\""
102
+ else
103
+ QUERY="conversationId:\"$CONV_ID\""
104
+ fi
105
+ fi
106
+
107
+ # Token via keychain mapping.
108
+ TOKEN_KEY=""
109
+ if [ -f "$PREFS" ]; then
110
+ TOKEN_KEY=$(python3 -c "
111
+ import json
112
+ try:
113
+ p = json.load(open('$PREFS'))
114
+ print(p.get('global', {}).get('keychainMapping', {}).get('graylog') or '')
115
+ except Exception:
116
+ print('')
117
+ ")
118
+ fi
119
+ [ -z "$TOKEN_KEY" ] && TOKEN_KEY="${USER}_Graylog_Access_Token"
120
+
121
+ # Locate the credential helper via the resolver so Copilot-only installs work.
122
+ # shellcheck disable=SC1090,SC1091
123
+ . "$HOME/.claude/lib/credential-store-resolver.sh" 2>/dev/null \
124
+ || . "$HOME/.copilot/lib/credential-store-resolver.sh" 2>/dev/null \
125
+ || { printf '%s\n' '{"status":"blocked","reason":"missing-credential-helper","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2; exit 2; }
126
+
127
+ TOKEN=$("$CRED_STORE" get "$TOKEN_KEY" 2>/dev/null || true)
128
+ if [ -z "$TOKEN" ]; then
129
+ printf '%s\n' '{"status":"blocked","reason":"missing-token","service":"graylog","expected_key":"'"$TOKEN_KEY"'"}' >&2
130
+ exit 2
131
+ fi
132
+
133
+ API_BASE="https://$HOST/api"
134
+
135
+ graylog_get() {
136
+ # Usage: graylog_get <path-and-query-relative-to-API_BASE>
137
+ # Prints "<body>\n<http_code>"; the caller splits the last line off.
138
+ local path="$1"
139
+ local url="$API_BASE/$path"
140
+ # PAT basic auth: username=<token>, password=literal "token". The credential
141
+ # goes through a curl config via process substitution so it never lands in
142
+ # argv (argv is visible to ps).
143
+ curl -sS --max-time "$TIMEOUT" --connect-timeout 5 -w "\n%{http_code}" \
144
+ -K <(printf 'user = "%s:token"\n' "$TOKEN") \
145
+ -H "Accept: application/json" \
146
+ -H "X-Requested-By: multi-agent-pipeline" \
147
+ "$url" 2>/dev/null || true
148
+ }
149
+
150
+ # Graylog relative universal search.
151
+ ENC_QUERY=$(QUERY_IN="$QUERY" python3 -c "
152
+ import os, urllib.parse as up
153
+ print(up.quote(os.environ['QUERY_IN']))
154
+ ")
155
+ SEARCH_PATH="search/universal/relative?query=$ENC_QUERY&range=$RANGE&limit=$LIMIT&sort=timestamp:desc"
156
+
157
+ RESP=$(graylog_get "$SEARCH_PATH")
158
+ HTTP=$(printf '%s' "$RESP" | tail -n1)
159
+ BODY=$(printf '%s' "$RESP" | sed '$d')
160
+
161
+ # A genuine auth rejection on a reachable host is the only hard failure.
162
+ case "$HTTP" in
163
+ 401|403)
164
+ echo "ERR: Graylog auth rejected (HTTP $HTTP)" >&2
165
+ exit 3 ;;
166
+ esac
167
+
168
+ # Any other non-200 (empty code on connection failure, 5xx, timeout) degrades
169
+ # to an empty result so a log fetch never blocks a run.
170
+ DEGRADED="false"
171
+ DEGRADE_REASON=""
172
+ if [ "$HTTP" != "200" ]; then
173
+ DEGRADED="true"
174
+ DEGRADE_REASON="graylog-unreachable"
175
+ BODY=""
176
+ fi
177
+
178
+ SRC_HOST="$HOST" SRC_TRX="$TRX_ID" SRC_CONV="$CONV_ID" SRC_QUERY="$QUERY" \
179
+ DEGRADED_IN="$DEGRADED" DEGRADE_REASON_IN="$DEGRADE_REASON" \
180
+ BODY_IN="$BODY" LIMIT_IN="$LIMIT" \
181
+ python3 - <<'PY'
182
+ import json, os, datetime
183
+
184
+ def safe_load(s):
185
+ try:
186
+ return json.loads(s) if s else None
187
+ except Exception:
188
+ return None
189
+
190
+ body = safe_load(os.environ.get("BODY_IN") or "") or {}
191
+ raw = body.get("messages") or []
192
+
193
+ try:
194
+ limit = int(os.environ.get("LIMIT_IN") or "200")
195
+ except Exception:
196
+ limit = 200
197
+
198
+ messages = []
199
+ for item in raw[:limit]:
200
+ m = item.get("message") if isinstance(item, dict) else None
201
+ if not isinstance(m, dict):
202
+ continue
203
+ reserved = ("timestamp", "source", "level", "message")
204
+ fields = {k: v for k, v in m.items() if k not in reserved}
205
+ messages.append({
206
+ "timestamp": m.get("timestamp") or "",
207
+ "source": m.get("source") or "",
208
+ "level": m.get("level") if isinstance(m.get("level"), (int, float)) else None,
209
+ "message": (m.get("message") or "")[:2000],
210
+ "fields": fields,
211
+ })
212
+
213
+ trx = os.environ.get("SRC_TRX") or None
214
+ conv = os.environ.get("SRC_CONV") or None
215
+ total = body.get("total_results")
216
+ if not isinstance(total, int):
217
+ total = len(messages)
218
+
219
+ result = {
220
+ "fetchedAt": datetime.datetime.utcnow().isoformat() + "Z",
221
+ "source": {
222
+ "host": os.environ["SRC_HOST"],
223
+ "transactionId": trx,
224
+ "conversationId": conv,
225
+ "query": os.environ.get("SRC_QUERY") or "",
226
+ },
227
+ "totalResults": total,
228
+ "messages": messages,
229
+ "degraded": os.environ.get("DEGRADED_IN") == "true",
230
+ "degradeReason": (os.environ.get("DEGRADE_REASON_IN") or None),
231
+ }
232
+ print(json.dumps(result, ensure_ascii=False))
233
+ PY