* Rename agnes-metadata.json to marketplace-metadata.json
Curated marketplace enrichment file (.claude-plugin/agnes-metadata.json)
becomes marketplace-metadata.json. Clean cut, no fallback — curators of
upstream marketplace repos must rename the file on their side.
Python API renames mirror the file rename: read_agnes_metadata →
read_marketplace_metadata, AGNES_METADATA_REL → MARKETPLACE_METADATA_REL,
AGNES_METADATA_MAX_BYTES → MARKETPLACE_METADATA_MAX_BYTES. Synth Claude
Code marketplace strip rule (.agnes/** + the metadata file) follows the
new filename.
* Marketplace detail polish: window cover + 715:310 aspect + helper alignment
- Plugin & item (skill/agent) detail hero: 160x160 square cover replaced
with a macOS-style window frame (3 traffic-light dots + titlebar label
showing the entity name). Body is constrained to 715:310 so curator-
uploaded covers no longer crop to a square. Window is 380px wide; meta
column and absolutely-positioned top-right install/remove actions stay
put. Fallback when no cover_photo_url (translucent gradient + PL/SK/AG
initials) is unchanged, just inside the window body.
- Inner skill/agent cards in the plugin detail's Internal structure
section adopt the same 715:310 aspect (was fixed 78px tall). No window
chrome on inner cards — just the matching proportions so covers read
consistently across hero, grid tiles, and listing cards.
- Curated nested item helper text ("This skill is part of ... — add the
bundle to your stack to use it") now stacks UNDER the "Open parent
plugin" button instead of being a side-by-side flex sibling in the
actions-row. Added align-self: flex-end so the 260px helper box
anchors at the right edge of the 300px actions column, matching the
button's right edge.
* Marketplace My tab: surface the same category + type filters as Flea
- Frontend: mp-cat-row and mp-type-row now show on tab=my (previously
hidden — type was flea-only, category was flea/curated-only). Curated
browse stays plugin-only and continues to hide the type pills.
fetchOne() sends the `type` param for tab=my too, so the items
endpoint's existing my-branch filter actually receives it.
- Backend categories endpoint, tab=my branch: when the type filter is
set to skill/agent, skip counting curated subscriptions. Curated
plugins are always type='plugin', so they wouldn't survive the items
endpoint's type filter; including them in the category counts made
the pill numbers overstate what users could actually see in the
grid. type=None or type='plugin' keeps the previous behaviour.
- CHANGELOG entry under [Unreleased].
* Marketplace plugin detail: render rich content from marketplace-metadata.json
Adds five optional plugin-level fields to marketplace-metadata.json and
renders them on the curated plugin detail page + listing card:
* display_name — friendly h1 / listing-card name / mac-window titlebar
label (overrides the technical plugin id)
* tagline — punchy 1-line value prop for the hero subtitle and the
listing card description (replacing the verbose marketplace.json
description on cards)
* description — multi-paragraph markdown body, server-side rendered
through markdown-it-py and sanitized through nh3 with a
description-scoped allowlist (no iframes / no raw HTML / no
javascript: links). Powers the "What it does" panel.
* use_cases[] — {title, description, prompt} entries that render as a
3-column "When to use it" card grid; each card shows the literal
prompt as a code chip so users can copy-paste into Claude Code.
* sample_interaction — {user, assistant} dialog rendered in a Claude
Code-style dark Catppuccin Mocha transcript panel: monospace user
row with a green ">" prompt indicator + sans-serif assistant body
with markdown formatting (peach bold, yellow italic, pink inline
code, mantle-dark fenced code blocks).
All five fields are optional; UI sections only render when populated,
so plugins without enrichment look identical to before. Fields are
read on-demand from the working tree (cached by mtime per marketplace
slug) so curator edits land at the next request without waiting for
a sync cycle — same pattern as the existing inner-skill/agent
enrichment path. No DB schema bump.
Skill / agent rich-content rendering is deferred to a later phase
(needs a source-of-truth decision: extend plugin.yml? LLM-generate
from SKILL.md / agent.md?). The schema accepts the same fields at
skill/agent level today for forward compatibility but the UI ignores
them for now.
Also: stripped a stale `background-color: var(--bg)` from the global
`code` rule in style.css (was making inline code visually disappear
on the page background).
* Skill / agent detail: render rich content from marketplace-metadata.json
Brings the skill/agent detail pages to parity with the plugin detail
page. Same rich-content schema (display_name, tagline, description as
markdown, use_cases[], sample_interaction) plus two per-item additions:
* invocation — curator-provided literal command string. When set,
overrides the computed "<manifest_name>:<inner_name>" chip and
cleanly supports both "/" skill prefix and "@" agent prefix (the
hardcoded "/" in the chip markup is hidden when the curator provides
the invocation, so /grpn-eng:query <q> and @grpn-eng:cto-architect
both render correctly).
* when_to_use — markdown disambiguation block ("Use this for X. For
similar Y, see /other-skill") rendered into a new "When to use this"
panel below the Example section.
Skill / agent category is now per-item overridable in
marketplace-metadata.json. When absent, the API keeps the parent
plugin's category as the badge so existing items don't lose their
category until curators opt in to per-item categorization.
The new "Example" Q&A panel uses the same Claude Code-style dark
Catppuccin Mocha transcript treatment as the plugin detail —
monospace user row with a green ">" prompt indicator + sans-serif
assistant body with markdown formatting.
All new fields are optional and read on-demand from the working tree.
Skills / agents whose marketplace-metadata.json doesn't carry rich
content render exactly the same way they did before (frontmatter
description + computed slash command + cover from existing v32
enrichment). No DB schema bump.
* Fix TypeError in skill / agent detail when curator sets per-item category
`curated_skill_detail` and `curated_agent_detail` were passing both
`**parent` (from `_curated_inner_parent_fields`, which returns the
parent plugin's category as a fallback) and `**enrichment` (from
`_curated_inner_enrichment`, which returns the per-item category
override when the curator set one) into `InnerDetailResponse(...)`.
Python function-call kwargs unpacking with overlapping keys raises
`TypeError: got multiple values for keyword argument 'category'`
— it doesn't merge like a literal dict does. The bug only surfaced
when the marketplace-metadata.json carried a `category` field at
skill / agent level (curator opting into per-item categorization);
items without that override hit the endpoint cleanly because only
parent provided the key.
Fix: build `merged = {**parent, **enrichment}` first (literal-dict
syntax DOES merge, with the right-hand-side winning) and unpack the
merged dict. Curator override still wins via the merge order, and
the same pattern is future-proof for any other field that lands in
both layers later.
Plus a regression test in test_marketplace_metadata.py asserting
that the inner-resolver carries `category` for downstream merging.
* Marketplace detail: tolerate partial curator JSON
Server constructed UseCase / SampleInteraction via raw dict indexing
(uc["title"], sample["assistant"]), so a curator commit missing any
required Pydantic field crashed the whole plugin / skill / agent detail
endpoint with a 500. Route both constructions through _safe_use_case /
_safe_sample_interaction helpers — partial input silently drops the
malformed card / section instead of breaking the page.
Regression test in test_marketplace_api.py covers the three shapes:
use_case missing a key, use_case with an empty string, and
sample_interaction with only user (no assistant). Sibling rich fields
still render.
* Address PR-251 review (must-fixes + S2/S3 polish) + release-cut 0.50.0
Five must-fixes from the review pass (3 from @cvrysanek's two-stage
review, 2 from my independent pass), plus the 0.50.0 release-cut as the
last commit on this PR per CLAUDE.md (CLAUDE.md "Release-cut belongs
to the PR" rule added in v0.49.1).
Must-fixes
----------
1. Cache eviction: bounded LRU instead of per-marketplace predicate.
The previous predicate (`k[0] == marketplace_id and k[1] != mtime_ns`)
only swept stale entries for the CURRENT marketplace; with N>100
distinct marketplaces each holding one mtime key, the cap silently
failed and memory grew linearly. Replaced with OrderedDict-backed
bounded LRU at cap=256, drop oldest insert on overflow.
Cache stress test pinned in test_marketplace_metadata.py.
2. Render CPU cap: per-field byte cap on description / when_to_use /
sample_interaction.assistant via MARKETPLACE_METADATA_FIELD_MAX_BYTES
(= 64 KiB). Without this, a 1 MiB curator markdown body × QPS =
curator-controlled CPU burn through pure-Python markdown-it-py.
Truncation respects UTF-8 boundaries and logs a warning so the
curator sees the cap fire on the next sync. Test for cap +
UTF-8-boundary preservation.
3. Inner-detail bypassed the metadata cache. _curated_inner_enrichment,
_curated_inner_cover, and curated_detail all called
read_marketplace_metadata directly, defeating the mtime cache the
plugin listing already shared. Routed all three through
_read_metadata_cached so skill/agent detail hits are O(1) re-parses
per marketplace per mtime instead of O(QPS).
4. Truthy-vs-presence trap in plugin/inner enrichment merge. API-layer
writers used `if resolved.get(k):` which silently dropped any
future falsy-but-valid resolver field (bool featured=False, int
priority=0, str category=''). Switched to presence check
(`if k in resolved`) so the resolver is the authority on field
presence; `{**parent, **enrichment}` merge respects whatever the
resolver decided to ship.
5. Vendor-agnostic OSS cleanup. Removed operator-specific token
references (/grpn-eng:, @grpn-eng:, .foundryai/) from
src/marketplace_metadata.py docstring, app/web/templates/
marketplace_item_detail.html JS comment, docs/curated-marketplace-
format.md, and tests/test_marketplace_metadata.py fixtures. Replaced
with generic /my-plugin:tool / @my-agent:role / .example/ placeholders.
CHANGELOG
---------
- New "### Fixed (PR #251 follow-ups)" section documenting all 4
code-side must-fixes
- New "### Internal" section noting the vendor cleanup + new tests
- BREAKING bullet for the file rename now covers operator-side
migration: running instances see plugin enrichment disappear from
the UI until upstream curator renames + nightly sync overwrites the
working tree; POST /api/marketplaces/{id}/sync forces refresh sooner
- Stripped /grpn-eng: leaks from the existing skill/agent rich-content
bullet
Tests
-----
128 targeted tests pass (test_marketplace_metadata, test_marketplace_api,
test_marketplace, test_markdown_render, test_marketplace_synth_strip,
test_marketplace_filter). New tests added:
- 6 XSS regression tests on render_safe (javascript:/data:/vbscript:
schemes via autolink, reference link, and mixed-case + positive
http/https/mailto + noopener noreferrer rel)
- 3 byte-cap tests (truncation + UTF-8 boundary + under-cap pass-through)
- 1 cache eviction stress test (>256 marketplaces -> bounded at cap)
- 1 truthy-vs-presence resolver-contract test
Release-cut
-----------
- pyproject.toml 0.49.1 -> 0.50.0 (minor; BREAKING file rename per
pre-1.0 CHANGELOG note: "breaking changes called out under Changed
or Removed with the BREAKING marker")
- CHANGELOG [Unreleased] -> [0.50.0] - 2026-05-12, new empty
[Unreleased] on top.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
556 lines
22 KiB
Python
556 lines
22 KiB
Python
"""Parser + resolver for upstream ``.claude-plugin/marketplace-metadata.json``.
|
||
|
||
Each curated marketplace repo can ship a sibling file next to ``marketplace.json``
|
||
that adds Agnes-only enrichment (cover photos, video URLs, doc links, category
|
||
overrides) per plugin / skill / agent. Claude Code ignores this file because
|
||
its contract reads only ``marketplace.json`` — see
|
||
``app/marketplace_server/packager.py`` for the ZIP-stripping rule that keeps
|
||
the synth Claude Code marketplace clean of Agnes-only files.
|
||
|
||
Two read paths exist:
|
||
|
||
* :func:`read_marketplace_metadata` — invoked from the sync pipeline
|
||
(``src/marketplace.py``). Lenient: missing file, malformed JSON, and partial
|
||
schemas all fall back to ``{}`` rather than aborting the sync.
|
||
* :func:`resolve_plugin_metadata` — given a parsed metadata blob and a plugin
|
||
name, return the plugin-level enrichment as a dict ready for
|
||
:meth:`MarketplacePluginsRepository.replace_for_marketplace` (with
|
||
``cover_photo_url`` / ``video_url`` / ``doc_links`` / ``category`` keys
|
||
resolved into served-URL form).
|
||
|
||
Skill / agent sub-trees stay nested in the parsed blob — the inner-detail
|
||
endpoint reads them on demand at request time. The reasoning lives in the
|
||
plan: keeping per-skill metadata out of DuckDB matches the existing pattern
|
||
where SKILL.md frontmatter is parsed lazily from disk.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from src.marketplace_asset_validation import (
|
||
DocLinkRef,
|
||
parse_cover_photo_ref,
|
||
parse_doc_link,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
MARKETPLACE_METADATA_REL = Path(".claude-plugin") / "marketplace-metadata.json"
|
||
"""Path inside the cloned marketplace working tree where the file is expected.
|
||
Sibling to ``marketplace.json`` so curators have one well-known place to put
|
||
both Claude Code and Agnes-side metadata."""
|
||
|
||
MARKETPLACE_METADATA_MAX_BYTES = 1 * 1024 * 1024
|
||
"""Hard cap on the size of an marketplace-metadata.json file.
|
||
|
||
The file is curator-controlled and read into memory in full before parsing.
|
||
Without a cap, a curator could commit a multi-GB document and OOM the sync
|
||
worker (or — more interestingly — slip past the size check and use deep
|
||
nesting to blow the parser's recursion stack). 1 MB is generous: a maximal
|
||
real-world metadata file with covers, docs, and categories for ~50 plugins
|
||
sits well under 100 KB."""
|
||
|
||
|
||
def read_marketplace_metadata(marketplace_root: Path) -> Dict[str, Any]:
|
||
"""Load the marketplace-metadata.json document from a cloned marketplace.
|
||
|
||
Returns the parsed dict on success, or ``{}`` when the file is missing,
|
||
unreadable, or contains malformed JSON. A malformed file logs a warning
|
||
so the curator notices on the admin sync log — but never aborts the
|
||
upstream sync, mirroring the existing handling in
|
||
:func:`src.marketplace.read_plugins`.
|
||
|
||
The schema is documented in ``docs/curated-marketplace-format.md``.
|
||
Top-level shape::
|
||
|
||
{
|
||
"version": 1,
|
||
"plugins": {
|
||
"<plugin-name>": { ...plugin-level enrichment... }
|
||
}
|
||
}
|
||
"""
|
||
path = marketplace_root / MARKETPLACE_METADATA_REL
|
||
if not path.is_file():
|
||
return {}
|
||
try:
|
||
size = path.stat().st_size
|
||
except OSError as e:
|
||
logger.warning("marketplace-metadata: %s stat failed: %s", path, e)
|
||
return {}
|
||
if size > MARKETPLACE_METADATA_MAX_BYTES:
|
||
logger.warning(
|
||
"marketplace-metadata: %s exceeds %d-byte cap (%d bytes), refusing to read",
|
||
path, MARKETPLACE_METADATA_MAX_BYTES, size,
|
||
)
|
||
return {}
|
||
try:
|
||
text = path.read_text(encoding="utf-8")
|
||
except OSError as e:
|
||
logger.warning("marketplace-metadata: %s unreadable: %s", path, e)
|
||
return {}
|
||
try:
|
||
data = json.loads(text)
|
||
except (ValueError, RecursionError) as e:
|
||
# ValueError covers malformed-JSON; RecursionError covers a curator
|
||
# who tries to crash the sync via deeply-nested structure that fits
|
||
# under the size cap (e.g. ``{"a":{"a":{"a":...}}}``). Both reduce
|
||
# to the same outcome — degrade gracefully so one bad upstream
|
||
# doesn't abort the whole sync.
|
||
logger.warning(
|
||
"marketplace-metadata: %s parse failed (%s), treating as empty: %s",
|
||
path, type(e).__name__, e,
|
||
)
|
||
return {}
|
||
if not isinstance(data, dict):
|
||
logger.warning(
|
||
"marketplace-metadata: %s top-level must be an object, got %s",
|
||
path, type(data).__name__,
|
||
)
|
||
return {}
|
||
return data
|
||
|
||
|
||
def get_plugin_section(metadata: Dict[str, Any], plugin_name: str) -> Dict[str, Any]:
|
||
"""Return the per-plugin sub-tree, or ``{}`` if absent or malformed.
|
||
|
||
Curator-facing schema is keyed by plugin name (matches the ``name`` field
|
||
in ``marketplace.json``). Stripping nested invalid types keeps downstream
|
||
consumers from special-casing missing keys.
|
||
"""
|
||
plugins = metadata.get("plugins") if isinstance(metadata, dict) else None
|
||
if not isinstance(plugins, dict):
|
||
return {}
|
||
section = plugins.get(plugin_name)
|
||
return section if isinstance(section, dict) else {}
|
||
|
||
|
||
def get_inner_section(
|
||
metadata: Dict[str, Any],
|
||
plugin_name: str,
|
||
kind: str,
|
||
inner_name: str,
|
||
) -> Dict[str, Any]:
|
||
"""Return the per-skill / per-agent sub-tree under a plugin.
|
||
|
||
``kind`` must be ``"skills"`` or ``"agents"``. Returns ``{}`` when any
|
||
layer of the lookup chain is missing or the wrong type.
|
||
"""
|
||
if kind not in ("skills", "agents"):
|
||
return {}
|
||
plugin_section = get_plugin_section(metadata, plugin_name)
|
||
inner_map = plugin_section.get(kind)
|
||
if not isinstance(inner_map, dict):
|
||
return {}
|
||
inner = inner_map.get(inner_name)
|
||
return inner if isinstance(inner, dict) else {}
|
||
|
||
|
||
def _validated_doc_links(raw: Any, log_prefix: str) -> List[DocLinkRef]:
|
||
"""Run each ``doc_links[]`` entry through :func:`parse_doc_link`.
|
||
|
||
Rejected entries are logged at WARNING and dropped — surviving entries are
|
||
returned in source order so the curator's ordering is preserved in the UI.
|
||
"""
|
||
if not isinstance(raw, list):
|
||
return []
|
||
out: List[DocLinkRef] = []
|
||
for i, entry in enumerate(raw):
|
||
ok, value = parse_doc_link(entry)
|
||
if not ok:
|
||
logger.warning("%s doc_links[%d] rejected: %s", log_prefix, i, value)
|
||
continue
|
||
out.append(value) # type: ignore[arg-type]
|
||
return out
|
||
|
||
|
||
def _validated_string(raw: Any, field_name: str, log_prefix: str) -> str:
|
||
"""Return ``raw`` stripped, or ``""`` for non-string / empty values.
|
||
|
||
Used for the plain-text rich fields (display_name, tagline) where the
|
||
curator-facing UI requires a single line. Markdown bodies (description,
|
||
sample_interaction.assistant) skip this and keep multi-line content.
|
||
"""
|
||
if raw is None:
|
||
return ""
|
||
if not isinstance(raw, str):
|
||
logger.warning(
|
||
"%s %s rejected: not a string (got %s)",
|
||
log_prefix, field_name, type(raw).__name__,
|
||
)
|
||
return ""
|
||
return raw.strip()
|
||
|
||
|
||
#: Per-field byte cap for markdown content. Stops a curator from committing a
|
||
#: 1 MB markdown body (under the file-level ``MARKETPLACE_METADATA_MAX_BYTES``
|
||
#: cap) and turning every plugin/inner-detail request into curator-controlled
|
||
#: CPU burn × QPS via the pure-Python ``markdown-it-py`` renderer. 64 KiB is
|
||
#: well above any plausible "What it does" / "When to use" / sample-assistant
|
||
#: body — overruns are truncated with a warning so the curator can see they
|
||
#: hit the cap on the next sync.
|
||
MARKETPLACE_METADATA_FIELD_MAX_BYTES = 64 * 1024
|
||
|
||
|
||
def _validated_markdown(raw: Any, field_name: str, log_prefix: str) -> str:
|
||
"""Return ``raw`` stripped of leading / trailing whitespace, preserving
|
||
interior structure (blank lines, indentation) so the markdown renderer
|
||
can interpret paragraphs / lists / fenced code blocks correctly. Empty
|
||
or wrong-type input collapses to ``""``. Per-field byte cap enforced
|
||
via UTF-8 length (matches what the renderer pays for) so curator-
|
||
controlled markdown can't dominate request CPU."""
|
||
if raw is None:
|
||
return ""
|
||
if not isinstance(raw, str):
|
||
logger.warning(
|
||
"%s %s rejected: not a string (got %s)",
|
||
log_prefix, field_name, type(raw).__name__,
|
||
)
|
||
return ""
|
||
encoded_len = len(raw.encode("utf-8"))
|
||
if encoded_len > MARKETPLACE_METADATA_FIELD_MAX_BYTES:
|
||
logger.warning(
|
||
"%s %s truncated: %d bytes exceeds per-field cap %d",
|
||
log_prefix, field_name, encoded_len, MARKETPLACE_METADATA_FIELD_MAX_BYTES,
|
||
)
|
||
# Truncate to the cap measured in UTF-8 bytes; use a generous slice
|
||
# of CHARS first, then bisect down on bytes (cheap; runs once when
|
||
# the cap is hit, not in the hot path).
|
||
encoded = raw.encode("utf-8")[:MARKETPLACE_METADATA_FIELD_MAX_BYTES]
|
||
# Drop trailing partial UTF-8 sequence (max 3 trailing bytes).
|
||
for trim in range(4):
|
||
try:
|
||
raw = encoded[: len(encoded) - trim].decode("utf-8")
|
||
break
|
||
except UnicodeDecodeError:
|
||
continue
|
||
return raw.strip("\n").rstrip()
|
||
|
||
|
||
def _validated_use_cases(raw: Any, log_prefix: str) -> List[Dict[str, str]]:
|
||
"""Validate ``use_cases[]`` from a metadata block.
|
||
|
||
Each surviving entry is a dict with exactly the three string keys the
|
||
template expects: ``title``, ``description``, ``prompt``. Entries
|
||
missing any of those (or with non-string values) are dropped with a
|
||
warning so the curator can see what went wrong in the sync log.
|
||
|
||
Source order is preserved.
|
||
"""
|
||
if raw is None:
|
||
return []
|
||
if not isinstance(raw, list):
|
||
logger.warning(
|
||
"%s use_cases rejected: not a list (got %s)",
|
||
log_prefix, type(raw).__name__,
|
||
)
|
||
return []
|
||
out: List[Dict[str, str]] = []
|
||
for i, entry in enumerate(raw):
|
||
if not isinstance(entry, dict):
|
||
logger.warning(
|
||
"%s use_cases[%d] rejected: not an object", log_prefix, i,
|
||
)
|
||
continue
|
||
title = entry.get("title")
|
||
description = entry.get("description")
|
||
prompt = entry.get("prompt")
|
||
if not all(isinstance(v, str) and v.strip()
|
||
for v in (title, description, prompt)):
|
||
logger.warning(
|
||
"%s use_cases[%d] rejected: missing title/description/prompt",
|
||
log_prefix, i,
|
||
)
|
||
continue
|
||
out.append({
|
||
"title": title.strip(), # type: ignore[union-attr]
|
||
"description": description.strip(), # type: ignore[union-attr]
|
||
"prompt": prompt.strip(), # type: ignore[union-attr]
|
||
})
|
||
return out
|
||
|
||
|
||
def _validated_sample_interaction(
|
||
raw: Any, log_prefix: str,
|
||
) -> Optional[Dict[str, str]]:
|
||
"""Validate ``sample_interaction``: ``{user, assistant}`` both required.
|
||
|
||
Returns ``None`` when either side is missing or wrong-typed — the UI
|
||
only renders this section when both halves of the dialog exist, so
|
||
partial input never reaches the template.
|
||
"""
|
||
if raw is None:
|
||
return None
|
||
if not isinstance(raw, dict):
|
||
logger.warning(
|
||
"%s sample_interaction rejected: not an object (got %s)",
|
||
log_prefix, type(raw).__name__,
|
||
)
|
||
return None
|
||
user = raw.get("user")
|
||
assistant = raw.get("assistant")
|
||
if not (isinstance(user, str) and user.strip()
|
||
and isinstance(assistant, str) and assistant.strip()):
|
||
logger.warning(
|
||
"%s sample_interaction rejected: user/assistant both required",
|
||
log_prefix,
|
||
)
|
||
return None
|
||
# `assistant` is rendered as markdown in the UI — apply the same per-field
|
||
# byte cap that ``description`` and ``when_to_use`` use so a single
|
||
# `sample_interaction.assistant` of 1 MiB can't dominate request CPU.
|
||
return {
|
||
"user": user.strip(),
|
||
"assistant": _validated_markdown(
|
||
assistant, "sample_interaction.assistant", log_prefix,
|
||
),
|
||
}
|
||
|
||
|
||
def resolve_plugin_metadata(
|
||
metadata: Dict[str, Any],
|
||
plugin_name: str,
|
||
) -> Dict[str, Any]:
|
||
"""Resolve plugin-level enrichment into the dict shape persisted to DB.
|
||
|
||
Returns a dict with keys (any of which may be missing when the upstream
|
||
file didn't supply that field):
|
||
|
||
Visual / classification (persisted in ``marketplace_plugins``):
|
||
* ``cover_photo_ref`` — ``("internal", path)`` or ``("external", url)``
|
||
tuple, or ``None``. The caller (sync pipeline) feeds it through the
|
||
asset mirror to produce the final served URL.
|
||
* ``video_url`` — string or ``None``. Always external; never mirrored.
|
||
* ``category`` — string or ``None``. Overrides ``marketplace.json``
|
||
category for this plugin.
|
||
* ``doc_links`` — list of :class:`DocLinkRef`. May be empty.
|
||
|
||
Rich user-facing content (read on-demand by the request handlers, NOT
|
||
persisted in the DB — curator edits land immediately, no sync needed):
|
||
* ``display_name`` — string (single line). Friendly name shown on the
|
||
detail-page h1 and listing card, falling back to the raw plugin name
|
||
when missing.
|
||
* ``tagline`` — string (single line). Hero subtitle and 2-line listing
|
||
card description.
|
||
* ``description`` — string (markdown body). Rendered through
|
||
:func:`app.markdown_render.render_safe` before reaching the
|
||
``description_long_html`` API field.
|
||
* ``use_cases`` — list of ``{title, description, prompt}`` dicts.
|
||
* ``sample_interaction`` — ``{user, assistant}`` dict or ``None``.
|
||
|
||
* ``raw_section`` — the original dict (for the inner-detail path that
|
||
needs to drill into ``skills`` / ``agents``).
|
||
"""
|
||
section = get_plugin_section(metadata, plugin_name)
|
||
if not section:
|
||
return {}
|
||
|
||
log_prefix = f"marketplace-metadata plugin={plugin_name}:"
|
||
out: Dict[str, Any] = {"raw_section": section}
|
||
|
||
cover = section.get("cover_photo")
|
||
if cover is not None:
|
||
ok, value = parse_cover_photo_ref(cover)
|
||
if ok:
|
||
out["cover_photo_ref"] = value
|
||
else:
|
||
logger.warning("%s cover_photo rejected: %s", log_prefix, value)
|
||
|
||
video = section.get("video_url")
|
||
if isinstance(video, str) and video.strip():
|
||
# video_url is always external; reuse cover_photo_ref's URL test only
|
||
# to keep error reporting consistent. Internal video paths are
|
||
# nonsensical (videos in git → no thanks) so we accept anything that
|
||
# *looks* like an http(s) URL and leave deeper sanity to the
|
||
# frontend embed code.
|
||
if video.strip().lower().startswith(("http://", "https://")):
|
||
out["video_url"] = video.strip()
|
||
else:
|
||
logger.warning("%s video_url must be http(s)://", log_prefix)
|
||
|
||
category = section.get("category")
|
||
if isinstance(category, str) and category.strip():
|
||
out["category"] = category.strip()
|
||
|
||
out["doc_links"] = _validated_doc_links(section.get("doc_links"), log_prefix)
|
||
|
||
# Rich user-facing fields (added 2026-05-12 for plugin-level rich content
|
||
# rendering). All optional — UI sections only render when present.
|
||
display_name = _validated_string(
|
||
section.get("display_name"), "display_name", log_prefix,
|
||
)
|
||
if display_name:
|
||
out["display_name"] = display_name
|
||
tagline = _validated_string(
|
||
section.get("tagline"), "tagline", log_prefix,
|
||
)
|
||
if tagline:
|
||
out["tagline"] = tagline
|
||
description = _validated_markdown(
|
||
section.get("description"), "description", log_prefix,
|
||
)
|
||
if description:
|
||
out["description"] = description
|
||
use_cases = _validated_use_cases(section.get("use_cases"), log_prefix)
|
||
if use_cases:
|
||
out["use_cases"] = use_cases
|
||
sample_interaction = _validated_sample_interaction(
|
||
section.get("sample_interaction"), log_prefix,
|
||
)
|
||
if sample_interaction is not None:
|
||
out["sample_interaction"] = sample_interaction
|
||
|
||
return out
|
||
|
||
|
||
def resolve_inner_metadata(
|
||
metadata: Dict[str, Any],
|
||
plugin_name: str,
|
||
kind: str,
|
||
inner_name: str,
|
||
) -> Dict[str, Any]:
|
||
"""Same shape as :func:`resolve_plugin_metadata`, scoped to skill or agent.
|
||
|
||
Rich user-facing fields (display_name / tagline / description /
|
||
use_cases / sample_interaction / when_to_use / invocation / category)
|
||
mirror the plugin-level set and are read on-demand at request time.
|
||
All optional — UI sections only render when populated. Skill/agent
|
||
inherits parent plugin's category when no override is set; the
|
||
rich-content layer is the only place to opt INTO per-item category.
|
||
"""
|
||
section = get_inner_section(metadata, plugin_name, kind, inner_name)
|
||
if not section:
|
||
return {}
|
||
|
||
log_prefix = (
|
||
f"marketplace-metadata plugin={plugin_name} {kind[:-1]}={inner_name}:"
|
||
)
|
||
out: Dict[str, Any] = {"raw_section": section}
|
||
|
||
cover = section.get("cover_photo")
|
||
if cover is not None:
|
||
ok, value = parse_cover_photo_ref(cover)
|
||
if ok:
|
||
out["cover_photo_ref"] = value
|
||
else:
|
||
logger.warning("%s cover_photo rejected: %s", log_prefix, value)
|
||
|
||
video = section.get("video_url")
|
||
if isinstance(video, str) and video.strip():
|
||
if video.strip().lower().startswith(("http://", "https://")):
|
||
out["video_url"] = video.strip()
|
||
else:
|
||
logger.warning("%s video_url must be http(s)://", log_prefix)
|
||
|
||
out["doc_links"] = _validated_doc_links(section.get("doc_links"), log_prefix)
|
||
|
||
# Rich user-facing fields (parity with plugin-level rich content from
|
||
# the 2026-05-12 redesign). All optional — UI hides each section when
|
||
# the corresponding field is absent.
|
||
display_name = _validated_string(
|
||
section.get("display_name"), "display_name", log_prefix,
|
||
)
|
||
if display_name:
|
||
out["display_name"] = display_name
|
||
tagline = _validated_string(
|
||
section.get("tagline"), "tagline", log_prefix,
|
||
)
|
||
if tagline:
|
||
out["tagline"] = tagline
|
||
# Per-item category override — when set, wins over the parent plugin's
|
||
# category. When absent, the API layer keeps the parent's category as
|
||
# the fallback so existing skill/agent pages don't lose their badge
|
||
# until curators opt in to per-item categorization.
|
||
category = _validated_string(
|
||
section.get("category"), "category", log_prefix,
|
||
)
|
||
if category:
|
||
out["category"] = category
|
||
description = _validated_markdown(
|
||
section.get("description"), "description", log_prefix,
|
||
)
|
||
if description:
|
||
out["description"] = description
|
||
use_cases = _validated_use_cases(section.get("use_cases"), log_prefix)
|
||
if use_cases:
|
||
out["use_cases"] = use_cases
|
||
sample_interaction = _validated_sample_interaction(
|
||
section.get("sample_interaction"), log_prefix,
|
||
)
|
||
if sample_interaction is not None:
|
||
out["sample_interaction"] = sample_interaction
|
||
when_to_use = _validated_markdown(
|
||
section.get("when_to_use"), "when_to_use", log_prefix,
|
||
)
|
||
if when_to_use:
|
||
out["when_to_use"] = when_to_use
|
||
# invocation is a single-line literal command the curator wants users
|
||
# to copy-paste (e.g. "/my-plugin:tool <your question>"). When absent,
|
||
# the API/template falls back to the computed
|
||
# "<manifest_name>:<inner_name>" so legacy items still show a chip.
|
||
invocation = _validated_string(
|
||
section.get("invocation"), "invocation", log_prefix,
|
||
)
|
||
if invocation:
|
||
out["invocation"] = invocation
|
||
|
||
return out
|
||
|
||
|
||
def collect_external_urls(
|
||
plugin_resolved: Dict[str, Any],
|
||
) -> List[Tuple[str, str]]:
|
||
"""Return ``[(kind, url)]`` tuples for every external URL the asset mirror
|
||
needs to fetch for this plugin.
|
||
|
||
``kind`` is one of ``"cover"`` or ``"doc"``, used by the mirror to pick
|
||
the cache sub-directory. Internal paths are skipped — they're served from
|
||
the git working tree directly, no mirror needed.
|
||
"""
|
||
urls: List[Tuple[str, str]] = []
|
||
cover_ref = plugin_resolved.get("cover_photo_ref")
|
||
if isinstance(cover_ref, tuple) and cover_ref[0] == "external":
|
||
urls.append(("cover", cover_ref[1]))
|
||
for link in plugin_resolved.get("doc_links") or []:
|
||
if isinstance(link, DocLinkRef) and link.kind == "external":
|
||
urls.append(("doc", link.url))
|
||
return urls
|
||
|
||
|
||
def collect_all_external_urls(
|
||
metadata: Dict[str, Any],
|
||
plugin_name: str,
|
||
) -> List[Tuple[str, str]]:
|
||
"""Walk plugin + every nested skill / agent and return all external URLs.
|
||
|
||
The plugin-level sync flow uses this to seed the mirror fetch list — by
|
||
fetching inner-level external URLs at sync time too, the request-time
|
||
skill / agent detail render can look them up in the manifest and drop
|
||
entries Agnes can't deliver, matching the plugin-level behavior.
|
||
|
||
Cover URLs and doc URLs from skills/agents share the per-plugin cache
|
||
namespace (``${DATA_DIR}/marketplace-cache/<slug>/<plugin>/...``) — the
|
||
inner sub-tree is keyed by URL, not by skill name, so two skills inside
|
||
the same plugin pointing at the same external URL share the cache entry.
|
||
"""
|
||
out: List[Tuple[str, str]] = []
|
||
plugin_resolved = resolve_plugin_metadata(metadata, plugin_name)
|
||
out.extend(collect_external_urls(plugin_resolved))
|
||
|
||
plugin_section = get_plugin_section(metadata, plugin_name)
|
||
for kind in ("skills", "agents"):
|
||
inner_map = plugin_section.get(kind)
|
||
if not isinstance(inner_map, dict):
|
||
continue
|
||
for inner_name in inner_map.keys():
|
||
inner_resolved = resolve_inner_metadata(
|
||
metadata, plugin_name, kind, inner_name,
|
||
)
|
||
out.extend(collect_external_urls(inner_resolved))
|
||
return out
|
||
|
||
|