agnes-the-ai-analyst/src/marketplace_metadata.py
minasarustamyan dc5e0e0d11
Marketplace UX overhaul: rich plugin/skill/agent detail + filename rename (#251)
* 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>
2026-05-12 08:38:39 +00:00

556 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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