Applies the practices from Anthropic's "new rules of context engineering for Claude 5 generation models" to this repo's always-on context. AGENTS.md (symlinked as CLAUDE.md) drops content derivable from the filesystem — the module-by-module structure tour, the Makefile target list, and most of the style section, all of which Claude reads directly from internal/, the Makefile, .golangci and web/.prettierrc. What stays is the part that isn't derivable: the Goose migration rules, the v1 additive-only API contract, multi-repo boundaries, and the workspace gotchas. Also fixes a dead pointer to .claude/skills/deployment-debugging, which does not exist; the runbook is the dev-environment-debugging skill. The external-contributor AI disclosure block moves to docs/ai-contributions.md, reached by a one-line pointer, so it costs nothing in the common case where no external PR is being prepared. issue-to-pr sheds the generic agent hygiene now covered by the harness system prompt and keeps the Silo-specific gates. Its commit trailer no longer pins a stale model name. scripts/jellycompat-diff.sh replaces the hand-typed curl/python one-liners the jellycompat-diagnosis skill used to carry as prose. It unions item keys across all returned items rather than reading item[0], which was hiding fields present on only some items. Verified: make verify-local-paths, bash -n, shellcheck, and a mock two-server run of the diff script. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
356 lines
9.7 KiB
Bash
Executable File
356 lines
9.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
printf 'usage: %s --path PATH [--json]\n' "${0##*/}" >&2
|
|
printf '\n' >&2
|
|
printf 'Compare a Silo jellycompat response with a reference Jellyfin response.\n' >&2
|
|
printf '\n' >&2
|
|
printf 'Required environment variables:\n' >&2
|
|
printf ' SILO_BASE_URL SILO_TOKEN SILO_USER_ID\n' >&2
|
|
printf ' JF_BASE_URL JF_TOKEN JF_USER_ID\n' >&2
|
|
}
|
|
|
|
endpoint_path=
|
|
json_output=0
|
|
|
|
while [[ "$#" -gt 0 ]]; do
|
|
case "$1" in
|
|
--path)
|
|
if [[ "$#" -lt 2 ]]; then
|
|
printf '%s\n' "error: --path requires a value" >&2
|
|
usage
|
|
exit 2
|
|
fi
|
|
endpoint_path=$2
|
|
shift 2
|
|
;;
|
|
--path=*)
|
|
endpoint_path=${1#*=}
|
|
shift
|
|
;;
|
|
--json)
|
|
json_output=1
|
|
shift
|
|
;;
|
|
-h | --help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
printf 'error: unknown argument: %s\n' "$1" >&2
|
|
usage
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$endpoint_path" ]]; then
|
|
printf '%s\n' "error: --path is required" >&2
|
|
usage
|
|
exit 2
|
|
fi
|
|
|
|
if [[ "$endpoint_path" != /* ]]; then
|
|
printf '%s\n' "error: --path must start with /" >&2
|
|
exit 2
|
|
fi
|
|
|
|
require_env() {
|
|
local name=$1
|
|
|
|
if [[ -z "${!name:-}" ]]; then
|
|
printf 'error: required environment variable %s is unset or empty\n' "$name" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
require_env SILO_BASE_URL
|
|
require_env SILO_TOKEN
|
|
require_env SILO_USER_ID
|
|
require_env JF_BASE_URL
|
|
require_env JF_TOKEN
|
|
require_env JF_USER_ID
|
|
|
|
temp_dir=$(mktemp -d)
|
|
trap 'rm -rf "$temp_dir"' EXIT
|
|
|
|
FETCH_CURL_EXIT=0
|
|
FETCH_HTTP_STATUS=000
|
|
FETCH_TIME_SECONDS=
|
|
|
|
fetch_side() {
|
|
local base_url=$1
|
|
local token=$2
|
|
local request_path=$3
|
|
local body_file=$4
|
|
local error_file=$5
|
|
local metrics
|
|
|
|
FETCH_CURL_EXIT=0
|
|
FETCH_HTTP_STATUS=000
|
|
FETCH_TIME_SECONDS=
|
|
|
|
metrics=$(
|
|
curl \
|
|
--silent \
|
|
--show-error \
|
|
--output "$body_file" \
|
|
--write-out $'%{http_code}\t%{time_total}' \
|
|
--header "X-Emby-Token: ${token}" \
|
|
"${base_url%/}${request_path}" \
|
|
2>"$error_file"
|
|
) || FETCH_CURL_EXIT=$?
|
|
|
|
IFS=$'\t' read -r FETCH_HTTP_STATUS FETCH_TIME_SECONDS <<<"$metrics" || true
|
|
}
|
|
|
|
silo_path=${endpoint_path//\{userId\}/$SILO_USER_ID}
|
|
jf_path=${endpoint_path//\{userId\}/$JF_USER_ID}
|
|
|
|
fetch_side "$SILO_BASE_URL" "$SILO_TOKEN" "$silo_path" "$temp_dir/silo.body" "$temp_dir/silo.error"
|
|
silo_curl_exit=$FETCH_CURL_EXIT
|
|
silo_http_status=$FETCH_HTTP_STATUS
|
|
silo_time_seconds=$FETCH_TIME_SECONDS
|
|
|
|
fetch_side "$JF_BASE_URL" "$JF_TOKEN" "$jf_path" "$temp_dir/jellyfin.body" "$temp_dir/jellyfin.error"
|
|
jf_curl_exit=$FETCH_CURL_EXIT
|
|
jf_http_status=$FETCH_HTTP_STATUS
|
|
jf_time_seconds=$FETCH_TIME_SECONDS
|
|
|
|
python3 - \
|
|
"$json_output" \
|
|
"$endpoint_path" \
|
|
"$temp_dir/silo.body" \
|
|
"$temp_dir/silo.error" \
|
|
"$silo_curl_exit" \
|
|
"$silo_http_status" \
|
|
"$silo_time_seconds" \
|
|
"$silo_path" \
|
|
"$temp_dir/jellyfin.body" \
|
|
"$temp_dir/jellyfin.error" \
|
|
"$jf_curl_exit" \
|
|
"$jf_http_status" \
|
|
"$jf_time_seconds" \
|
|
"$jf_path" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
|
|
def parse_status(raw_status):
|
|
try:
|
|
return int(raw_status)
|
|
except ValueError:
|
|
return 0
|
|
|
|
|
|
def parse_latency_ms(raw_seconds):
|
|
try:
|
|
return round(float(raw_seconds) * 1000, 3)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def read_text(path):
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
return handle.read().strip()
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
|
|
def analyze_side(body_path, error_path, curl_exit, raw_status, raw_latency, request_path):
|
|
status = parse_status(raw_status)
|
|
body_bytes = os.path.getsize(body_path) if os.path.exists(body_path) else 0
|
|
request_error = read_text(error_path) if curl_exit != 0 else None
|
|
response_error = None
|
|
total_record_count = None
|
|
items_length = None
|
|
shape = None
|
|
item_keys = set()
|
|
|
|
if curl_exit == 0 and 200 <= status < 300:
|
|
try:
|
|
with open(body_path, encoding="utf-8") as handle:
|
|
payload = json.load(handle)
|
|
|
|
# Three response shapes are all legitimate here:
|
|
# envelope — {Items, TotalRecordCount, ...} from list endpoints
|
|
# array — bare BaseItemDto[] from LocalTrailers / SpecialFeatures
|
|
# object — the item itself, from detail endpoints like /Items/{id}
|
|
# Treating anything but the envelope as an error, or as zero items,
|
|
# would report a false clean on exactly the endpoints most likely to
|
|
# have drifted.
|
|
if isinstance(payload, list):
|
|
shape = "array"
|
|
items = payload
|
|
elif isinstance(payload, dict):
|
|
total_record_count = payload.get("TotalRecordCount")
|
|
if "Items" in payload:
|
|
shape = "envelope"
|
|
items = payload["Items"]
|
|
if not isinstance(items, list):
|
|
raise ValueError("Items is not an array")
|
|
else:
|
|
shape = "object"
|
|
items = [payload]
|
|
else:
|
|
raise ValueError("top-level JSON value is neither an object nor an array")
|
|
|
|
items_length = len(items)
|
|
for index, item in enumerate(items):
|
|
if not isinstance(item, dict):
|
|
raise ValueError(f"item[{index}] is not an object")
|
|
item_keys.update(item)
|
|
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error:
|
|
response_error = str(error)
|
|
|
|
bytes_per_item = None
|
|
if items_length:
|
|
bytes_per_item = round(body_bytes / items_length, 2)
|
|
|
|
return {
|
|
"requestPath": request_path,
|
|
"curlExitCode": curl_exit,
|
|
"httpStatus": status,
|
|
"latencyMs": parse_latency_ms(raw_latency),
|
|
"bodyBytes": body_bytes,
|
|
"totalRecordCount": total_record_count,
|
|
"responseShape": shape,
|
|
"itemsLength": items_length,
|
|
"itemKeys": sorted(item_keys),
|
|
"bytesPerItem": bytes_per_item,
|
|
"requestError": request_error,
|
|
"responseError": response_error,
|
|
}
|
|
|
|
|
|
def is_success(side):
|
|
return (
|
|
side["curlExitCode"] == 0
|
|
and 200 <= side["httpStatus"] < 300
|
|
and side["responseError"] is None
|
|
)
|
|
|
|
|
|
def display_value(value):
|
|
if value is None:
|
|
return "not present"
|
|
return str(value)
|
|
|
|
|
|
def display_keys(keys):
|
|
return ", ".join(keys) if keys else "(none)"
|
|
|
|
|
|
def print_side(name, side):
|
|
latency = side["latencyMs"]
|
|
latency_display = f"{latency:.3f} ms" if latency is not None else "not available"
|
|
bytes_per_item = side["bytesPerItem"]
|
|
bytes_per_item_display = (
|
|
f"{bytes_per_item:.2f}" if bytes_per_item is not None else "not available"
|
|
)
|
|
|
|
print(f"{name}:")
|
|
print(f" Request path: {side['requestPath']}")
|
|
print(f" HTTP status: {side['httpStatus']}")
|
|
print(f" Latency: {latency_display}")
|
|
print(f" Response body: {side['bodyBytes']} bytes")
|
|
print(f" Response shape: {display_value(side['responseShape'])}")
|
|
print(f" TotalRecordCount: {display_value(side['totalRecordCount'])}")
|
|
print(f" Items length: {display_value(side['itemsLength'])}")
|
|
print(f" Per-item keys ({len(side['itemKeys'])}): {display_keys(side['itemKeys'])}")
|
|
print(f" Approx. bytes per item: {bytes_per_item_display}")
|
|
if side["requestError"]:
|
|
print(f" Request error: {side['requestError']}")
|
|
if side["responseError"]:
|
|
print(f" Response error: {side['responseError']}")
|
|
|
|
|
|
json_output = sys.argv[1] == "1"
|
|
endpoint_path = sys.argv[2]
|
|
silo = analyze_side(
|
|
sys.argv[3],
|
|
sys.argv[4],
|
|
int(sys.argv[5]),
|
|
sys.argv[6],
|
|
sys.argv[7],
|
|
sys.argv[8],
|
|
)
|
|
jellyfin = analyze_side(
|
|
sys.argv[9],
|
|
sys.argv[10],
|
|
int(sys.argv[11]),
|
|
sys.argv[12],
|
|
sys.argv[13],
|
|
sys.argv[14],
|
|
)
|
|
|
|
silo_keys = set(silo["itemKeys"])
|
|
jellyfin_keys = set(jellyfin["itemKeys"])
|
|
silo_bytes_per_item = silo["bytesPerItem"]
|
|
jellyfin_bytes_per_item = jellyfin["bytesPerItem"]
|
|
bytes_warning = (
|
|
silo_bytes_per_item is not None
|
|
and jellyfin_bytes_per_item is not None
|
|
and silo_bytes_per_item > jellyfin_bytes_per_item * 2
|
|
)
|
|
|
|
# A shape difference is itself a compat bug — an envelope where Jellyfin sends a
|
|
# bare array breaks clients regardless of whether the per-item keys agree.
|
|
shape_mismatch = (
|
|
is_success(silo)
|
|
and is_success(jellyfin)
|
|
and silo["responseShape"] != jellyfin["responseShape"]
|
|
)
|
|
|
|
comparison = {
|
|
"path": endpoint_path,
|
|
"ok": is_success(silo) and is_success(jellyfin),
|
|
"silo": silo,
|
|
"jellyfin": jellyfin,
|
|
"diff": {
|
|
"missingFromSilo": sorted(jellyfin_keys - silo_keys),
|
|
"siloOnly": sorted(silo_keys - jellyfin_keys),
|
|
"shared": sorted(silo_keys & jellyfin_keys),
|
|
"responseShapeMismatch": shape_mismatch,
|
|
"siloBytesPerItemExceedsJellyfinByMoreThan2x": bytes_warning,
|
|
},
|
|
}
|
|
|
|
if json_output:
|
|
print(json.dumps(comparison, separators=(",", ":"), sort_keys=True))
|
|
else:
|
|
print_side("Silo", silo)
|
|
print()
|
|
print_side("Jellyfin", jellyfin)
|
|
print()
|
|
print("Diff:")
|
|
print(
|
|
" Jellyfin keys missing from Silo "
|
|
f"({len(comparison['diff']['missingFromSilo'])}): "
|
|
f"{display_keys(comparison['diff']['missingFromSilo'])}"
|
|
)
|
|
print(
|
|
f" Silo-only keys ({len(comparison['diff']['siloOnly'])}): "
|
|
f"{display_keys(comparison['diff']['siloOnly'])}"
|
|
)
|
|
print(
|
|
f" Keys in both ({len(comparison['diff']['shared'])}): "
|
|
f"{display_keys(comparison['diff']['shared'])}"
|
|
)
|
|
if shape_mismatch:
|
|
print(
|
|
f" WARNING: response shape differs — Silo returned "
|
|
f"{silo['responseShape']}, Jellyfin returned {jellyfin['responseShape']}."
|
|
)
|
|
if bytes_warning:
|
|
print(
|
|
" WARNING: Silo's approximate bytes per item exceeds "
|
|
"Jellyfin's by more than 2x."
|
|
)
|
|
|
|
sys.exit(0 if comparison["ok"] else 1)
|
|
PY
|