@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.
- package/CHANGELOG.md +60 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +68 -32
- package/pipeline/commands/multi-agent/setup/SKILL.md +9 -3
- package/pipeline/lib/context-link-extractor.sh +40 -0
- package/pipeline/lib/fetch-figma-annotations.sh +237 -0
- package/pipeline/lib/fetch-graylog.sh +233 -0
- package/pipeline/multi-agent-refs/analysis-template.md +233 -123
- package/pipeline/multi-agent-refs/features/external-context-injection.md +3 -0
- package/pipeline/multi-agent-refs/keychain.md +3 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +21 -8
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +1 -1
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/analysis-spec.schema.json +78 -0
- package/pipeline/schemas/figma-project-config.schema.json +49 -0
- package/pipeline/schemas/prefs.schema.json +32 -0
- package/pipeline/scripts/fixtures/install-layout.tsv +4 -4
- package/pipeline/scripts/smoke-fetchers-offline.sh +66 -0
- package/pipeline/scripts/smoke-validate-analysis-doc.sh +161 -0
- package/pipeline/scripts/validate-analysis-doc.mjs +196 -0
|
@@ -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
|