diff --git a/CHANGELOG.md b/CHANGELOG.md index a1dbd8e..d79e708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,148 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C ## [Unreleased] +### Changed +- **BREAKING (marketplace identifier)**: synthetic plugin bundling flea + skills + agents renamed from `agnes-store-bundle` to `flea`. The + served `marketplace.json` now lists `flea` (previously + `agnes-store-bundle`); on-disk ZIP / git tree path is + `plugins/flea/` (previously `plugins/store-bundle/`). Claude Code + JSONL invocation prefix becomes `flea:` going + forward. **Clean cut — no legacy-prefix backward compat.** Historic + `usage_events` rows whose JSONL was written before the rename will + stay attributed as `source='builtin'` (acceptable in dev phase per + user direction; nothing to migrate). + + **Client rollover**: `agnes refresh-marketplace` will install the + new `flea@agnes` plugin and reset the local marketplace clone (the + old `plugins/store-bundle/` source folder gets removed from disk + via `git reset --hard`). Whether Claude Code itself auto-prunes + the orphan `agnes-store-bundle@agnes` registry entry is + undocumented in our codebase — to be verified empirically on the + dev VM. If the orphan entry lingers, users can manually run + `claude plugin uninstall agnes-store-bundle@agnes`. +- Marketplace detail page **Details sidebar** unified across all five + surfaces (curated plugin / flea plugin / curated inner skill+agent / + flea inner skill+agent / flea standalone skill+agent). Render order + now scans **identity → life-stage → telemetry → debug-tier**: + Curator / Owner → (Parent plugin for inner / Released for top-level) + → Last used → Active days → Version (flea standalone only) → + Bundle size. Drops the previous Slug row (debug-tier, never user- + relevant) from plugin detail and the Category + Installs rows + (duplicated hero badge + telemetry chip) from flea standalone + detail. Flea plugin Owner row now reads `d.owner_display` — the + fullname resolved via `users.name → users.email → owner_username` + — instead of the kebab-case `owner_username` slug. +- Flea marketplace cards and detail pages now render the user-friendly + **title** instead of the kebab-case `-by-` slug, the + owner's full name from `users.name` (with email → `owner_username` + fallback) instead of the bare username, and the optional **tagline** + as the hero subtitle (description still shows below the hero on + detail pages). Phase 2 of the Flea refactor — phase 1 (commit + `7f4cfcbb`) seeded the columns; phase 2 wires them through + `_flea_to_item`, `flea_detail`, and the two detail templates. + Breadcrumb last segment on `/marketplace/flea/{id}` drops the + suffixed slug fallback in favour of the title. +- Flea inner skill/agent detail pages + (`/marketplace/flea/{id}/skill/{name}`, `/agent/{name}`) now show + the parent plugin's **title** in the breadcrumb 3rd segment, the + hero "part of …" meta-row, the helper "This skill is part of …" + panel, and the Details sidebar's "Parent plugin" row. Sourced + from `store_entities.title` via + `_flea_inner_parent_fields.parent_display_name`; falls back to + `strip_archive_suffix(name)` for any legacy rows that somehow + lack a title. +- Flea standalone skill/agent detail (`/marketplace/flea/{id}` where + `type IN ('skill','agent')`) drops the hero meta-row that read + "by <author> · N installed · <size>". Install count is already + rendered in the hero telemetry chip below; owner + bundle size + live in the Details sidebar. The row was duplicating those three + values in a less-prominent position. +- Read paths (marketplace card name, detail manifest_name, response + `invocation_name`, My-Stack invocation, served-bundle manifest in + `marketplace_filter`) now source the suffixed slug from + `store_entities.synthetic_name` directly instead of recomputing + `-by-` on the fly. The column is NOT NULL + + the repo `create` / `update` / `archive` paths keep it in sync, so + reading it is safe; no fallback to a recompute — a missing value + would be a genuine bug worth surfacing as `KeyError`, not masked. + `suffixed_name()` stays as the primitive used by **write paths + only** (POST create insert, PUT rename collision check + new + suffix for `_rename_baked_tree` + new synthetic for `repo.update`, + archive new/old suffix for on-disk rename). `_suffixed_already_taken` + collision query swaps the inline `name || '-by-' || owner_username` + concat for `WHERE synthetic_name = ?` — indexable + single source + of truth. + +### Fixed +- Flea **plugin entity** cards (`/marketplace?tab=flea`) and detail + pages (`/marketplace/flea/{id}` for `type='plugin'`) now show the + sum of nested skill/agent invocations. Pre-fix the plugin-level + rollup pass in `services/session_processors/usage_lib.py:_aggregate_events` + was hardcoded to `source='curated'` only, so flea plugin entities + never got a `(source='flea', type='plugin', parent_plugin='', + name=)` aggregated row. The API path's + `_load_invocation_stats('flea')` filters `parent_plugin=''` and + returned nothing for plugin cards even though nested children had + correct rollup rows. Triggered by empirical observation on dev VM + (`codex-second-opinion-by-c-marustamyan` plugin showed 0 calls + while its three inner skills had 1+1+3 invocations). Fix extends + the aggregation pass to `source in ('curated', 'flea')` and + preserves the original source tag in the synthetic plugin row. + `USAGE_PROCESSOR_VERSION` bumped 8→9 so the reprocess pass fills + the new aggregated rows for historic data. +- Flea-market attribution layer now keys its lookup tables by + `store_entities.synthetic_name` instead of `name`, matching what + Claude Code writes in the JSONL invocation local-part + (`flea:` e.g. `flea:xlsx-by-c-marustamyan`). + Pre-fix every flea skill/agent invocation silently fell through to + `usage_events.source = 'builtin'` because the dict was keyed by + the un-suffixed `name`. Result: marketplace cards, detail + telemetry chips, and admin group-by-source had 0 flea invocations + even though raw events were arriving correctly. Both + `MarketplaceItemLookup` (live writer) and `_attribute_event` + (rollup rebuilder) updated; rollup `name`/`parent_plugin` + columns now carry the synthetic_name keyspace. API stats lookups + in `app/api/marketplace.py` switched from `entity["name"]` to + `entity["synthetic_name"]` (4 callsites: `_flea_to_item`, + `flea_detail`, two flea inner-detail endpoints). `_attribute_event` + also gains the flea-plugin-nested branch it was missing since + v6 — nested skills/agents inside flea plugins now flow into + rollup tables too. `USAGE_PROCESSOR_VERSION` bumped 7→8 so the + session-pipeline reprocess loop re-attributes existing events + with the corrected lookup. Closes issue #335. +- Flea-tab marketplace listing endpoint + (`GET /api/marketplace/items?tab=flea`) no longer issues an N+1 + query against `users`. The owner-display resolution previously + fired one `SELECT name, email FROM users WHERE id = ?` per item + inside the list comprehension; now batched into a single + `WHERE id IN (…)` prefetch via `_load_users_display`. With 50 + flea items per page that drops 51 queries to 2. + +### Added +- Flea-market upload + edit forms now collect a user-friendly **Title** + (humanized from the kebab-case `name`, acronym-aware: `mcp-builder` → + `MCP Builder`, `oauth-server-v2` → `OAuth Server V2`), an optional + **Short description** (`tagline`, ≤200 chars), and show a read-only + live preview of the final synthetic invocation slug + (`/-by-`) next to the Name field. Phase 1 of a + larger Flea refactor — fields are persisted on `store_entities` but + not yet rendered on marketplace cards / detail pages (Phase 2). Schema + v49 adds `title NOT NULL`, `tagline`, and `synthetic_name NOT NULL` + columns; backfill humanizes existing names (archive-suffix stripped + first) and composes synthetic from the deterministic formula. +- Schema **v50** adds a UNIQUE INDEX on `store_entities.synthetic_name` + (`idx_store_entities_synthetic_name`). v49 made `synthetic_name` the + canonical attribution key (rollup keyspace, JSONL invocation prefix, + marketplace bundle naming) but uniqueness was only enforced + application-side at upload/rename time via `_suffixed_already_taken`. + v50 promotes the invariant to the DB layer so admin DB hand-fixes or + future write-path bugs can't silently introduce duplicates. + DuckDB has no `ALTER TABLE ADD CONSTRAINT UNIQUE`, but + `CREATE UNIQUE INDEX` is functionally equivalent. Migration pre-checks + for existing duplicates and raises `RuntimeError` listing them rather + than letting the index create fail mid-way with a raw DuckDB error. + ## [0.54.28] — 2026-05-18 ### Fixed @@ -91,20 +233,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C now rejects `tables` dicts with more than 500 entries (ADV-008, ADV-009). - `GET /api/catalog/tables` now has a typed `response_model` (`CatalogTablesResponse`) so Swagger generates an accurate schema for that endpoint (ADV-007). - -### Internal -- Added `TestFullLifecycleFromInstaller` integration test class - (`tests/test_store_entity_versions.py`) covering the full - flea-market lifecycle from issuer / admin / subscribed-user - perspectives. Main test walks v1 upload → installer subscribes → - v2 promote → v3 blocked → admin force-overrides → restore v1, - asserting BOTH entity state AND served `marketplace.zip` bytes + - ETag at each transition. Plus 5 corner cases: - unsubscribed-user negative control, late-subscriber-during- - quarantine, non-owner privacy gate, second-restore reuse path - (PR #332 lifecycle validation), and archived-entity-keeps- - serving-installs (CLAUDE.md contract). - ## [0.54.24] — 2026-05-16 ### Fixed diff --git a/app/api/marketplace.py b/app/api/marketplace.py index 88af51f..b0339fa 100644 --- a/app/api/marketplace.py +++ b/app/api/marketplace.py @@ -22,7 +22,7 @@ import logging import re from collections import OrderedDict from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Tuple +from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple import duckdb from fastapi import APIRouter, Depends, HTTPException, Query @@ -52,7 +52,6 @@ from src.repositories.user_curated_subscriptions import ( ) from src.repositories.user_store_installs import UserStoreInstallsRepository from src.store_categories import STORE_CATEGORIES -from src.store_naming import suffixed_name logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/marketplace", tags=["marketplace"]) @@ -799,6 +798,7 @@ def _flea_to_item( entity: dict, *, installed_set: set, + users_display: Dict[str, Tuple[Optional[str], Optional[str]]], viewer_id: Optional[str] = None, stats: Optional[Dict[str, Dict]] = None, ) -> MarketplaceItem: @@ -815,13 +815,33 @@ def _flea_to_item( # (Claude Code's `/plugin` resolution) uses the renamed slug — we # don't strip there. from src.store_naming import strip_archive_suffix - display_name = strip_archive_suffix(entity["name"]) - invocation = suffixed_name(display_name, entity.get("owner_username") or "") + display_name_raw = strip_archive_suffix(entity["name"]) + # v49 phase-3: invocation is the stored synthetic_name. The column is + # NOT NULL (phase 1 migration + repo create/update/archive write + # paths keep it in sync), so reading it directly is safe and a + # missing value would be a real bug worth surfacing as KeyError + # rather than masking with a recompute. + invocation = entity["synthetic_name"] is_viewer_owner = bool(viewer_id and entity.get("owner_user_id") == viewer_id) - # v46: flea stats keyed by store_entities.name (rollup `name` column). - # The display name is post-archive-strip; use the raw row name to match - # what the lookup preload sees. - stat = (stats or {}).get(entity["name"], {}) + # v49 phase-5: rollup `name` column carries the synthetic_name (the + # post-rename keyspace used by `MarketplaceItemLookup`). Stats dict + # built by `_load_invocation_stats` is keyed by that same value, so + # the lookup uses `entity["synthetic_name"]`. + stat = (stats or {}).get(entity["synthetic_name"], {}) + # v49 phase-2: front the card with the user-friendly `title` (humanized, + # acronym-aware) via the existing `display_name` field — JS already + # has the chain `it.display_name || it.name` on cards. `tagline` + # rides the same chain JS uses for curated. Owner display resolves + # `users.name → users.email → owner_username` so cards no longer + # leak the kebab-case username (e.g. `c-marustamyan`) when the user + # has a real name on their account. Reads from a prefetched + # ``users_display`` map (one IN-query per page) — see + # ``_load_users_display`` callers in ``list_items``. + owner_display = _owner_display_from_map( + users_display, + entity["owner_user_id"], + entity.get("owner_username") or "", + ) return MarketplaceItem( id=f"flea-{entity['id']}", source="flea", @@ -829,7 +849,9 @@ def _flea_to_item( type=entity["type"], category=entity.get("category") or None, description=entity.get("description"), - owner=entity.get("owner_username"), + owner=owner_display, + display_name=entity.get("title"), + tagline=entity.get("tagline"), version=entity.get("version"), photo_url=photo_url, added=_to_iso(entity.get("created_at")), @@ -1069,9 +1091,13 @@ async def list_items( include_owner_id=include_owner, ) flea_stats = _load_invocation_stats(conn, "flea") + flea_users_display = _load_users_display( + conn, (r["owner_user_id"] for r in all_flea_rows), + ) items = [ _flea_to_item( r, installed_set=installed_set, + users_display=flea_users_display, viewer_id=user["id"], stats=flea_stats, ) @@ -1145,9 +1171,13 @@ async def list_items( flea_installs = UserStoreInstallsRepository(conn).list_for_user(user["id"]) flea_installed_set = {row["id"] for row in flea_installs} + flea_users_display = _load_users_display( + conn, (row["owner_user_id"] for row in flea_installs), + ) for entity in flea_installs: items.append(_flea_to_item( - entity, installed_set=flea_installed_set, stats=flea_stats, + entity, installed_set=flea_installed_set, + users_display=flea_users_display, stats=flea_stats, )) # Apply optional filters client-server-style for `my` tab (small N): @@ -1497,6 +1527,9 @@ def _resolve_owner_display( Mirrors the inline lookup ``app/web/router.py::store_detail`` already does so the marketplace API surfaces the same string the Store page shows. + + Single-row variant for detail endpoints. List endpoints must use + ``_load_users_display`` to avoid an N+1 against ``users``. """ row = conn.execute( "SELECT name, email FROM users WHERE id = ?", [owner_user_id] @@ -1506,6 +1539,41 @@ def _resolve_owner_display( return row[0] or row[1] or fallback +def _load_users_display( + conn: duckdb.DuckDBPyConnection, + user_ids: Iterable[str], +) -> Dict[str, Tuple[Optional[str], Optional[str]]]: + """Batch-fetch ``(name, email)`` for a set of user_ids — single round-trip. + + Returns a dict keyed by user_id. Use with ``_owner_display_from_map`` + inside list comprehensions to compose the same + ``users.name → users.email → fallback`` resolution that + ``_resolve_owner_display`` does per-row. + """ + ids = [u for u in {uid for uid in user_ids if uid}] + if not ids: + return {} + placeholders = ",".join("?" * len(ids)) + rows = conn.execute( + f"SELECT id, name, email FROM users WHERE id IN ({placeholders})", + ids, + ).fetchall() + return {r[0]: (r[1], r[2]) for r in rows} + + +def _owner_display_from_map( + users_display: Dict[str, Tuple[Optional[str], Optional[str]]], + owner_user_id: str, + fallback: str, +) -> str: + """Resolve owner display from a prefetched map, mirroring + ``_resolve_owner_display`` semantics.""" + row = users_display.get(owner_user_id) + if not row: + return fallback + return row[0] or row[1] or fallback + + def _get_plugin_row( conn: duckdb.DuckDBPyConnection, marketplace_id: str, @@ -1711,9 +1779,11 @@ async def flea_detail( a.detail_url = f"/marketplace/flea/{entity_id}/agent/{a.name}" # Per-item telemetry — same shape as curated_detail. Adoption # inherits from the parent flea plugin's install_count (no - # standalone install on inner items). + # standalone install on inner items). v49 phase-5: rollup + # `parent_plugin` for flea-plugin children carries the parent's + # synthetic_name (= what Claude Code writes in the JSONL prefix). inner_stats = _load_inner_items_stats_by_parent( - conn, "flea", entity["name"], + conn, "flea", entity["synthetic_name"], ) flea_parent_stack = int(entity.get("install_count") or 0) for s in skills: @@ -1759,7 +1829,8 @@ async def flea_detail( # the renamed-on-archive slug since that's what Claude Code resolves. from src.store_naming import strip_archive_suffix _flea_display_name = strip_archive_suffix(entity["name"]) - invocation = suffixed_name(_flea_display_name, entity.get("owner_username") or "") + # v49 phase-3: read the stored synthetic_name (NOT NULL invariant). + invocation = entity["synthetic_name"] # doc_paths is a JSON array of relative paths the uploader picked at upload # time; `app/api/store.py` serves them by basename via /api/store/.../docs/{filename}. @@ -1799,6 +1870,13 @@ async def flea_detail( entity_id=entity_id, plugin_name=_flea_display_name, manifest_name=invocation, + # v49 phase-2: surface the user-friendly title + short description + # via the existing curated-side fields. JS heroTitle chain already + # prefers `display_name`, and the hero-tagline element already + # reads `d.tagline` — flea now feeds the same chain instead of + # falling through to plugin_name (= kebab-case entity name). + display_name=entity.get("title"), + tagline=entity.get("tagline"), description=entity.get("description"), version=entity.get("version"), category=entity.get("category"), @@ -1825,9 +1903,10 @@ async def flea_detail( docs=docs, visibility_status=entity.get("visibility_status") or "approved", submission_status=submission_status, - # v46: flea telemetry keyed by entity.name (rollup `name` column), - # not entity_id — JSONL identifiers carry the entity name, not its UUID. - telemetry=_build_telemetry(conn, "flea", entity["name"]), + # v49 phase-5: flea telemetry keyed by entity.synthetic_name + # (rollup `name` column carries the post-rename keyspace, which + # is the same string Claude Code writes in the JSONL local-part). + telemetry=_build_telemetry(conn, "flea", entity["synthetic_name"]), ) @@ -1996,6 +2075,12 @@ def _flea_inner_parent_fields( enrichment file convention exists for flea bundles yet, so the same fallbacks the flea plugin detail hero uses (strip_archive_suffix on entity.name, owner display, entity.updated_at) populate the response. + + v49 phase-3: ``parent_display_name`` prefers the user-set ``title`` + column over the kebab-case ``name``. The frontend chain (breadcrumb, + hero "part of …", sidebar "Parent plugin", helper "This skill is part + of …") all read ``d.parent_display_name`` first, so a single source + swap drives every surface to the friendly form. """ from src.store_naming import strip_archive_suffix owner_display = _resolve_owner_display( @@ -2007,7 +2092,7 @@ def _flea_inner_parent_fields( "parent_author_name": owner_display or OWNER_TODO_PLACEHOLDER, "parent_updated_at": _to_iso(entity.get("updated_at")), "manifest_name": entity["name"], - "parent_display_name": strip_archive_suffix(entity["name"]), + "parent_display_name": entity.get("title") or strip_archive_suffix(entity["name"]), } @@ -2531,8 +2616,9 @@ async def flea_skill_detail( text, relpath = res fm = _parse_frontmatter(text) parent = _flea_inner_parent_fields(conn, entity) + # v49 phase-5: rollup `parent_plugin` carries the parent's synthetic_name. telemetry = _load_inner_item_stats( - conn, "flea", parent_plugin=entity["name"], name=skill_name, item_type="skill", + conn, "flea", parent_plugin=entity["synthetic_name"], name=skill_name, item_type="skill", ) return InnerDetailResponse( marketplace_id="", @@ -2583,8 +2669,9 @@ async def flea_agent_detail( except OSError: agent_size = 0 parent = _flea_inner_parent_fields(conn, entity) + # v49 phase-5: rollup `parent_plugin` carries the parent's synthetic_name. telemetry = _load_inner_item_stats( - conn, "flea", parent_plugin=entity["name"], name=agent_name, item_type="agent", + conn, "flea", parent_plugin=entity["synthetic_name"], name=agent_name, item_type="agent", ) return InnerDetailResponse( marketplace_id="", diff --git a/app/api/my_stack.py b/app/api/my_stack.py index 01904a2..dac4c4e 100644 --- a/app/api/my_stack.py +++ b/app/api/my_stack.py @@ -32,7 +32,6 @@ from src.repositories.user_curated_subscriptions import ( UserCuratedSubscriptionsRepository, ) from src.repositories.user_store_installs import UserStoreInstallsRepository -from src.store_naming import suffixed_name logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/my-stack", tags=["my-stack"]) @@ -182,7 +181,10 @@ async def get_my_stack( version=row["version"], owner_user_id=row["owner_user_id"], owner_username=row["owner_username"], - invocation_name=suffixed_name(raw_name, row["owner_username"]), + # v49 phase-3: stored synthetic_name (single source of + # truth). The column is NOT NULL and `list_for_user` + # selects it explicitly from the joined store_entities row. + invocation_name=row["synthetic_name"], install_count=int(row.get("install_count") or 0), photo_url=photo_url, installed_at=_to_iso(row.get("installed_at")), diff --git a/app/api/store.py b/app/api/store.py index 99a0671..694a3b3 100644 --- a/app/api/store.py +++ b/app/api/store.py @@ -104,7 +104,7 @@ def _suffixed_already_taken( The Store namespace is **flat** in Claude Code — two plugins/skills/agents that share a ``name`` collide in the served marketplace catalog (the ``manifest_name`` is unique-key for ``/plugin`` lookup) and on-disk inside - the ``agnes-store-bundle`` (skills//SKILL.md is the dir name). + the ``flea`` bundle (skills//SKILL.md is the dir name). ``sanitize_username`` is many-to-one (``alice.smith`` and ``alice_smith`` both → ``alice-smith``), so the per-owner UNIQUE on @@ -117,11 +117,15 @@ def _suffixed_already_taken( check so the same owner can re-upload under the original name after archive. The archive path renames the row to free the slug, so this flag is belt-and-braces. + + v49 phase-3: query the stored ``synthetic_name`` column instead of + the inline concat ``name || '-by-' || owner_username``. Phase 1's + migration backfilled the column for every row and the repo write + paths keep it in sync, so both expressions return the same set — + but querying the column is indexable and avoids divergence if the + naming formula ever changes (single source of truth). """ - sql = ( - "SELECT id FROM store_entities " - "WHERE name || '-by-' || owner_username = ?" - ) + sql = "SELECT id FROM store_entities WHERE synthetic_name = ?" params: List[Any] = [suffixed] if exclude_entity_id: sql += " AND id != ?" @@ -177,6 +181,14 @@ class StoreEntityResponse(BaseModel): # v32+ quarantine: surface visibility so /store browse can render # the corner badge on the submitter's own non-approved cards. visibility_status: Optional[str] = None + # v49 phase-1 Flea refactor — user-facing metadata. `title` is a + # humanized display name (acronym-aware), `tagline` is an optional + # 200-char short description, `synthetic_name` is the deterministic + # -by- baked into served bundles. Phase 1 only writes + # them; consuming surfaces (cards, detail, Claude Code) come later. + title: Optional[str] = None + tagline: Optional[str] = None + synthetic_name: Optional[str] = None class StoreEntityListResponse(BaseModel): @@ -204,6 +216,10 @@ class PreviewResponse(BaseModel): type: str name: Optional[str] = None description: Optional[str] = None + # v49: humanized form of `name` for pre-filling the Title input on + # the upload form. Computed server-side so the acronym dict has a + # single source of truth (src/store_naming.py:TITLE_ACRONYMS). + title: Optional[str] = None components: list[PreviewComponent] = [] @@ -553,8 +569,15 @@ def _entity_to_response( doc_paths=entity.get("doc_paths") or [], created_at=_to_iso(entity.get("created_at")), updated_at=_to_iso(entity.get("updated_at")), - invocation_name=suffixed_name(entity["name"], entity["owner_username"]), + # v49 phase-3: invocation_name comes from the stored + # synthetic_name column (single source of truth). The column is + # NOT NULL and the repo write paths keep it in lockstep with + # name + owner_username — any missing value is a real bug. + invocation_name=entity["synthetic_name"], visibility_status=entity.get("visibility_status") or "approved", + title=entity.get("title"), + tagline=entity.get("tagline"), + synthetic_name=entity.get("synthetic_name"), ) @@ -1313,10 +1336,13 @@ async def preview_entity( finally: Path(tmp.name).unlink(missing_ok=True) + from src.store_naming import humanize_name + extracted_name = meta.get("name") return PreviewResponse( type=type, - name=meta.get("name"), + name=extracted_name, description=meta.get("description"), + title=humanize_name(extracted_name) if extracted_name else None, components=[ PreviewComponent( type=row["type"], @@ -1345,6 +1371,10 @@ async def create_entity( description: Optional[str] = Form(None), category: Optional[str] = Form(None), video_url: Optional[str] = Form(None), + # v49 phase-1: user-facing metadata fields. Upload form pre-fills + # `title` from a humanizer over `name`; `tagline` is optional. + title: Optional[str] = Form(None), + tagline: Optional[str] = Form(None), photo: Optional[UploadFile] = File(None), docs: List[UploadFile] = File(default=[]), user: dict = Depends(get_current_user), @@ -1428,6 +1458,18 @@ async def create_entity( raise HTTPException(status_code=400, detail="invalid_name_format") final_description = description or meta.get("description") + # v49: Title is user-supplied; pre-filled in the upload form by + # JS humanize_name() with the same acronym dict the server uses, + # but always editable. Fall back to server-side humanize when the + # client omits the field (e.g. legacy uploaders, API integrations). + from src.store_naming import humanize_name + final_title = (title or "").strip() or humanize_name(final_name) or final_name + if len(final_title) > 100: + raise HTTPException(status_code=400, detail="title_too_long") + final_tagline = (tagline or "").strip() or None + if final_tagline is not None and len(final_tagline) > 200: + raise HTTPException(status_code=400, detail="tagline_too_long") + repo = StoreEntitiesRepository(conn) # Skip archived rows: archive renames the row to free the slot, # so a same-name re-upload after archive succeeds. Active rows @@ -1519,6 +1561,8 @@ async def create_entity( owner_username=username, type=type, name=final_name, + title=final_title, + synthetic_name=suffixed, description=final_description, category=category, version=version, @@ -1527,6 +1571,7 @@ async def create_entity( doc_paths=doc_rels, file_size=file_size, visibility_status=initial_visibility, + tagline=final_tagline, ) _audit( conn, @@ -1596,6 +1641,11 @@ async def update_entity( description: Optional[str] = Form(None), category: Optional[str] = Form(None), video_url: Optional[str] = Form(None), + # v49 phase-1 metadata. ``title`` and ``tagline`` are partial-update + # fields: omit to leave unchanged, send empty string to clear (only + # meaningful for ``tagline``; empty ``title`` is rejected). + title: Optional[str] = Form(None), + tagline: Optional[str] = Form(None), photo: Optional[UploadFile] = File(None), user: dict = Depends(get_current_user), conn: duckdb.DuckDBPyConnection = Depends(_get_db), @@ -1634,6 +1684,7 @@ async def update_entity( background_tasks=background_tasks, file=file, name=name, type=type, description=description, category=category, video_url=video_url, photo=photo, + title=title, tagline=tagline, user=user, conn=conn, ) @@ -1649,6 +1700,8 @@ async def _update_entity_locked( category: Optional[str], video_url: Optional[str], photo: Optional[UploadFile], + title: Optional[str], + tagline: Optional[str], user: dict, conn: duckdb.DuckDBPyConnection, ): @@ -1703,6 +1756,24 @@ async def _update_entity_locked( video_url = _validate_video_url(video_url) + # v49 phase-1: validate metadata fields. ``title`` left None means "no + # change"; empty string is rejected (title is NOT NULL). ``tagline`` + # supports empty-string clear via the repository sentinel. + new_title: Optional[str] = None + if title is not None: + stripped = title.strip() + if not stripped: + raise HTTPException(status_code=400, detail="title_required") + if len(stripped) > 100: + raise HTTPException(status_code=400, detail="title_too_long") + new_title = stripped + new_tagline: Optional[str] = None + if tagline is not None: + stripped_tagline = tagline.strip() + if len(stripped_tagline) > 200: + raise HTTPException(status_code=400, detail="tagline_too_long") + new_tagline = stripped_tagline # "" clears (repo treats falsy as NULL) + # Display-name change handled at the end (after bundle bake) so the # rename can target the version-bumped or current bundle dir. rename_to: Optional[str] = None @@ -1766,7 +1837,11 @@ async def _update_entity_locked( Path(tmp.name).unlink(missing_ok=True) _validate_and_extract_metadata(entity["type"], scratch) - suffixed = suffixed_name(entity["name"], entity["owner_username"]) + # v49 phase-3: read the stored synthetic_name. Entity row was + # loaded before any rename — `synthetic_name` is the OLD value + # baked-tree code expects (rename, when present, is applied + # below via _rename_baked_tree with NEW suffix). + suffixed = entity["synthetic_name"] # Bake into the staging dir — _bake_plugin_tree creates the # target if missing and does its own rmtree on existing # children, so the staging path being fresh is fine. @@ -1859,7 +1934,10 @@ async def _update_entity_locked( # keep serving the prior bundle under the prior slug. if rename_to is not None: owner_username = entity["owner_username"] - old_suffix = suffixed_name(entity["name"], owner_username) + # v49 phase-3: old_suffix reads the stored synthetic_name (entity + # was loaded before any rename was applied). new_suffix MUST be + # freshly computed — rename_to is a proposed value not yet in DB. + old_suffix = entity["synthetic_name"] new_suffix = suffixed_name(rename_to, owner_username) if file is None: @@ -1907,6 +1985,13 @@ async def _update_entity_locked( # Metadata-only column updates (name, description, category, photo, # video) — never bundle-derived (version / file_size) because the # new version isn't promoted to current until the LLM approves. + # v49: when ``rename_to`` is set, synthetic_name must move in lockstep + # so attribution lookups + the global suffix-uniqueness check stay + # accurate. owner_username is immutable, so the new synthetic is a + # pure function of the new name. + new_synthetic: Optional[str] = None + if rename_to is not None: + new_synthetic = suffixed_name(rename_to, entity["owner_username"]) repo.update( entity_id, name=rename_to, @@ -1914,6 +1999,9 @@ async def _update_entity_locked( category=category, photo_path=photo_rel, video_url=video_url, + title=new_title, + tagline=new_tagline, + synthetic_name=new_synthetic, ) # v46: rename no longer needs an explicit attribution refresh — the @@ -2529,7 +2617,7 @@ async def uninstall_entity( # `agnes admin store {pull,push}` CLI commands which back up the Store to a # git repo (or restore from one). Bundle format: # -# agnes-store-bundle.zip +# flea.zip # ├── manifest.json ← {"format":1,"generated_at":..., "entries":[...]} # └── entities// # ├── plugin/... ← canonical Claude Code plugin tree @@ -2756,7 +2844,7 @@ async def export_bundle( content=payload, media_type="application/zip", headers={ - "Content-Disposition": 'attachment; filename="agnes-store-bundle.zip"', + "Content-Disposition": 'attachment; filename="flea.zip"', "X-Bundle-Entry-Count": str(len(items)), }, ) diff --git a/app/web/router.py b/app/web/router.py index 8064db7..1b9c7f2 100644 --- a/app/web/router.py +++ b/app/web/router.py @@ -1315,10 +1315,17 @@ async def store_new( conn: duckdb.DuckDBPyConnection = Depends(_get_db), ): from src.store_categories import STORE_CATEGORIES + from src.store_naming import TITLE_ACRONYMS, sanitize_username + try: + owner_username = sanitize_username(user.get("email") or "") + except ValueError: + owner_username = "" ctx = _build_context( request, user=user, categories=list(STORE_CATEGORIES), guardrail=_guardrail_thresholds(), + title_acronyms=TITLE_ACRONYMS, + owner_username=owner_username, ) return templates.TemplateResponse(request, "store_upload.html", ctx) @@ -1375,6 +1382,7 @@ async def store_edit( if latest and latest.get("status") in ("pending_inline", "pending_llm"): pending_sub = latest + from src.store_naming import TITLE_ACRONYMS ctx = _build_context( request, user=user, entity=entity, @@ -1382,6 +1390,8 @@ async def store_edit( is_owner=entity["owner_user_id"] == user["id"], categories=list(STORE_CATEGORIES), pending_sub=pending_sub, + title_acronyms=TITLE_ACRONYMS, + owner_username=entity.get("owner_username") or "", ) return templates.TemplateResponse(request, "store_edit.html", ctx) diff --git a/app/web/templates/marketplace_item_detail.html b/app/web/templates/marketplace_item_detail.html index 9b1879a..3de63e9 100644 --- a/app/web/templates/marketplace_item_detail.html +++ b/app/web/templates/marketplace_item_detail.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}{{ item_name or inner_name or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %} +{% block title %}{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %} {% block content %}
@@ -86,10 +109,24 @@
- - Title + +
Human-friendly name shown on marketplace cards.
+
+ +
+ +
+ +
+ /{{ entity.synthetic_name or (entity.name ~ '-by-' ~ entity.owner_username) }} +
+
⚠ Changing the name renames the plugin slug for existing installers. They'll see the plugin renamed on their next sync @@ -97,6 +134,15 @@
+
+ + +
0 / 200 max
+
+