From c6c72b9c004bc307d73a22590f8f3ac0c4f0f6a3 Mon Sep 17 00:00:00 2001 From: minasarustamyan <156230623+minasarustamyan@users.noreply.github.com> Date: Tue, 19 May 2026 02:32:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(flea):=20marketplace=20refactor=20?= =?UTF-8?q?=E2=80=94=20data=20model,=20attribution,=20UI=20unification=20(?= =?UTF-8?q?#342)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(flea): phase-1 — title, tagline, synthetic_name columns + upload UX Schema v49 adds three user-facing metadata columns to store_entities: - title (NOT NULL) — humanized display name shown on marketplace surfaces in later phases. Acronym-aware humanizer in src/store_naming.py (27 entries: MCP, API, OAuth, S3, …) shared with the frontend via Jinja-injected dict so JS pre-fill and Python backfill produce identical output. - tagline (NULL, ≤200 chars) — optional short description for card listings. Long-form `description` stays. - synthetic_name (NOT NULL) — deterministic `-by-` stored as a column for indexing and as the single source of truth for attribution lookups in later phases. Today's bundle bake still uses suffixed_name() at the same call sites. Migration (_v48_to_v49_migrate, Python function — humanize has no SQL equivalent) backfills existing rows: title from humanize_name(strip_archive_suffix(name)), synthetic from the concat formula; tagline stays NULL. Idempotent (ADD COLUMN IF NOT EXISTS + SET NOT NULL no-op on re-run). Upload form (store_upload.html step 2) reorders fields: Title (pre-filled from server-side humanize, JS keeps it in sync until the user edits manually) → Name + dark synthetic preview on one row (matches marketplace_item_detail.html dark code styling, no copy button — preview only) → Short description with character counter → Description (unchanged). Edit form (store_edit.html) mirrors the layout with pre-filled values from the entity row. API: - POST /api/store/entities/preview returns `title` (humanized fallback) for upload form pre-fill. - POST + PUT /api/store/entities accept `title` and `tagline` form fields with 100/200-char validation; PUT recomputes synthetic_name when `name` changes (caller responsibility per repo contract). - StoreEntityResponse exposes all three new fields. Repository: - create() takes title + tagline + synthetic_name as optional kwargs with derived defaults (humanize_name(name) / concat) so existing test fixtures don't need to thread them. - update() supports partial updates on all three; tagline empty string clears via NULL sentinel. - archive() recomputes synthetic_name on rename to the archived slug so the column stays consistent with name. Tests: - New test_schema_v48_to_v49_migration.py: fresh install, populated-row backfill (incl. archived row strip), idempotence, NOT NULL constraint verification. - test_store_naming.py: 14 humanize parametrize cases + acronym dict invariants. - test_store_api.py::TestStoreV49Metadata: preview humanize, POST with explicit + fallback title, 100/200-char rejects, PUT partial update + synthetic recompute on rename. - Schema version assertion bumps (48 → 49) in test_db_schema_version, test_home_stats, test_schema_v42_migration, test_schema_v46_migration. Phase 1 only — surface rendering on cards / detail pages and Claude Code bundle propagation come in later phases. * feat(flea): phase-2 — wire title/tagline/owner through marketplace cards + detail pages Phase 1 (7f4cfcbb) populated the three new columns on store_entities; phase 2 surfaces them across the web presentation layer so the kebab- case slug + bare username no longer leak into user-facing copy. API: - `_flea_to_item` now takes `conn` (both callsites updated) and sets `display_name=entity.title`, `tagline=entity.tagline`, `owner= _resolve_owner_display(conn, owner_user_id, owner_username)` — matches the chain the curated path already uses (users.name → users.email → fallback). The card JS chain `it.display_name || it.name` then renders the friendly form; `name` stays at the suffixed slug as the technical identifier JS uses for fallbacks. - `flea_detail` adds `display_name` + `tagline` to PluginDetailResponse so the standalone skill/agent + plugin detail heroes pick them up through the existing `d.display_name` / `d.tagline` chains. - `_flea_inner_parent_fields` swaps `parent_display_name` from `strip_archive_suffix(name)` to `entity.title or strip_archive_suffix( name)`. Drives parent-plugin label in four surfaces at once: breadcrumb 3rd segment, hero "part of " meta-row, helper "This skill is part of " panel, and the Details sidebar's "Parent plugin" row. Templates — `marketplace_item_detail.html`: - Pre-render: browser title, hero h1, and hero-window-label read `(entity.title if entity else None) or inner_name or item_name or plugin_name` so the SSR shell shows the friendly title before the JS fetch lands (no flash of kebab-case). - Breadcrumb last segment for flea standalone drops the `d.manifest_name || heroTitle` fallback in favour of just `heroTitle` — manifest_name is the suffixed slug and users explicitly didn't want it in the path. - Hero meta-row for flea standalone is now hidden. The prior "by · N installed · " line duplicated install count (hero telemetry chip below), owner + bundle size (Details sidebar). Templates — `marketplace_plugin_detail.html`: - Same SSR pre-render swap (title, h1, window-label, crumb-name). - Hero tagline element starts hidden; JS shows it only when `d.tagline` is truthy. Pre-fix it fell back to `d.description` (long-form text), which read awkwardly under the h1 and pulled the hero too tall. Description still renders in the "What it does" panel below the hero. - Initial "Loading…" placeholder removed so entities without a tagline don't flash that text mid-fetch. Tests: - New `TestFleaPhase2Presentation` class in test_marketplace_api.py (6 cases): card title + tagline + full-name owner, owner fallback chain when users.name is NULL, flea_detail exposes title + tagline, tagline null when omitted, inner skill parent_display_name uses entity.title (explicit + humanize-fallback variants). - Updated `TestListItems.test_flea_lists_uploads` to assert both `display_name == "Alpha"` (humanized) and `name == "alpha-by-alice"` (suffixed slug compat). - Updated `TestWebPages.test_marketplace_flea_detail_page_renders` to look for the humanized title ("Page Skill") in the SSR shell instead of the kebab-case `page-skill`. * feat(flea): phase-3 — read synthetic_name from DB, suffixed_name() only on write Phase 1 added the column + backfill, repo write paths keep it in sync. Phase 3 routes every READ callsite through `store_entities.synthetic_name` directly instead of recomputing `-by-` on the fly, and switches the collision query off the inline string concat. The `suffixed_name()` primitive now lives exclusively in write flows. Read callsites updated (all read `entity["synthetic_name"]` directly, no fallback — the column is NOT NULL and a missing value would be a real bug worth surfacing as KeyError): - app/api/marketplace.py:_flea_to_item — card MarketplaceItem.name. - app/api/marketplace.py:flea_detail — PluginDetailResponse.manifest_name. - app/api/store.py:_entity_to_response — StoreEntityResponse.invocation_name. - app/api/store.py PUT bundle re-bake — `suffixed` passed to `_bake_plugin_tree`; entity is loaded pre-rename, so its synthetic_name is the OLD value `_bake_plugin_tree` expects. - app/api/store.py PUT rename — `old_suffix` for `_rename_baked_tree`. - app/api/my_stack.py — StoreInstallEntry.invocation_name. - src/marketplace_filter.py — manifest_name in served plugin entry. `suffixed_name` imports removed from marketplace.py, my_stack.py, and marketplace_filter.py (no remaining callsites). store.py keeps the import for its write paths: - POST create (`suffixed = suffixed_name(final_name, username)` → passed to `_bake_plugin_tree` and `repo.create(synthetic_name=...)`). - PUT rename collision check (`new_suffixed`). - PUT rename `new_suffix` for `_rename_baked_tree` (proposed value). - PUT rename `new_synthetic` for `repo.update(synthetic_name=...)`. - Archive `old_suffix` + `new_suffix` for `_rename_baked_tree` (retro-compute pre-archive value after `repo.archive` already overwrote the DB row with the post-archive synthetic). Collision SQL — `_suffixed_already_taken`: WHERE name || '-by-' || owner_username = ? (before) WHERE synthetic_name = ? (after) Same matches today (phase 1 backfill + NOT NULL invariant + write paths in sync); indexable + single source of truth going forward. Repository: - UserStoreInstallsRepository.list_for_user explicit SELECT extended with `se.title`, `se.tagline`, `se.synthetic_name` so my_stack and marketplace_filter callers can read them off the joined row. Tests: - test_store_api.py::test_invocation_name_reads_from_synthetic_column — upload entity, manually override the column with a non-canonical value, verify GET response returns the override (proves read path consumes the column, not recomputes). - test_marketplace_api.py::test_flea_card_and_detail_read_synthetic_name_from_db — same proof for `MarketplaceItem.name` (card) and `PluginDetailResponse.manifest_name` (detail). * feat(flea): phase-4 — rename agnes-store-bundle → flea (synthetic plugin) The synthetic plugin that wraps loose flea-market skills + agents into one Claude Code plugin is renamed from `agnes-store-bundle` to `flea`. Plugin-type flea uploads (their own standalone plugin entry) are unaffected. Constants: - src/marketplace_filter.py: - BUNDLE_PLUGIN_NAME: "agnes-store-bundle" → "flea" (Claude Code plugin manifest name + .claude-plugin/plugin.json name) - BUNDLE_PREFIXED_NAME: "store-bundle" → "flea" (on-disk ZIP / git tree path, now plugins/flea/...) Attribution layer (services/session_processors/usage_lib.py): - FLEA_BUNDLE_PREFIX: "agnes-store-bundle" → "flea". The JSONL invocation identifier going forward is `flea:`. - New `_LEGACY_FLEA_BUNDLE_PREFIXES = ("agnes-store-bundle",)`. `MarketplaceItemLookup.resolve()` + `_attribute_event()` accept BOTH the new and the legacy prefix so historic usage_events (~90-day retention) continue attributing to source='flea'. The tuple becomes a no-op once the rename has been live past the retention window — a follow-up commit can drop it then. - USAGE_PROCESSOR_VERSION bumped 6 → 7 so the session-pipeline reprocess loop re-runs attribution with the new + legacy prefix branches. User-facing copy: - /api/store/bundle.zip Content-Disposition filename: agnes-store-bundle.zip → flea.zip - `agnes admin store pull` default --out: agnes-store-bundle.zip → flea.zip - Docstrings + JS comment + welcome template comment updated. Tests: - skill_flea.jsonl fixture identifier updated to flea:flea-skill. - New skill_flea_legacy.jsonl with the legacy prefix for backward-compat coverage. - New test `test_legacy_agnes_store_bundle_prefix_resolves` replays the legacy fixture and asserts source='flea' attribution still lands. - All other test assertions / mocks substituted mechanically: test_session_processor_usage.py, test_usage_rollups.py, test_marketplace_filter_store.py, test_store_api.py, test_cli_refresh_marketplace.py. - `_seed_flea_entity` (test_usage_rollups.py) + `_seed_attribution` (test_session_processor_usage.py) helpers now supply the NOT NULL `title` + `synthetic_name` columns from phase 1, since they INSERT directly bypassing the repo's create() fallback. Client rollover note (CHANGELOG): `agnes refresh-marketplace` will install the new `flea@agnes` plugin and the local marketplace clone's `plugins/store-bundle/` source folder is removed via `git reset --hard`. Whether Claude Code itself auto-prunes the orphan `agnes-store-bundle @agnes` registry entry is undocumented — to verify empirically on the dev VM. If the orphan entry lingers, a follow-up will add targeted cleanup; until then users can manually run `claude plugin uninstall agnes-store-bundle@agnes`. Verified locally: 98 passed (session_processor_usage + usage_rollups + marketplace_filter_store + cli_refresh_marketplace) + 228 passed/2 skipped (store_api + marketplace_api + admin_store_submissions + store_entity_versions + store_repositories). * fix(flea): phase-5 — attribution keyspace mismatch (closes #335) Pre-fix every flea skill/agent invocation silently fell through to `usage_events.source = 'builtin'`. Root cause: lookup tables in `services/session_processors/usage_lib.py` keyed `_flea_entities` (and the derived `_flea_plugins` set) by `store_entities.name` — the un-suffixed display name. Claude Code writes invocations as `flea:` (e.g. `flea:xlsx-by-c-marustamyan`), so `dict.get(local)` always missed and the resolver fell through to builtin. Result: marketplace cards, detail telemetry chips, admin group-by-source all showed 0 flea invocations even when the raw JSONL stream was correct. Phase 1 added the `synthetic_name` column + backfill; phase 4 renamed the bundle prefix to `flea`; phase 5 finally flips the lookup keyspace to match what JSONL writes. usage_lib.py: - `MarketplaceItemLookup.__init__` preload: `SELECT synthetic_name, type FROM store_entities` (was `SELECT name, type`). `_flea_plugins` set derived from those keys, so it now carries synthetic_names too — matches what Claude Code writes when invoking a skill nested inside a flea plugin (`:`). - `rebuild_rollups` preload: same SELECT change; also derives `flea_plugins` and threads it through `_aggregate_events` / `_rebuild_window`. - `_attribute_event`: signature extended with `flea_plugins`; new branch `if prefix in flea_plugins: return ("flea", default_type, prefix, local)` for flea-plugin-nested skills/agents. This branch was added to `MarketplaceItemLookup.resolve()` in v6 (commit e076ebbe) but the rollup builder's helper was never updated to match, so nested skills inside flea plugins silently dropped out of the daily/window fact tables. - `USAGE_PROCESSOR_VERSION`: 7 → 8. Forces the session-pipeline reprocess loop to re-attribute existing usage_events rows with the corrected lookup so rollup tables fill correctly on the next tick. marketplace.py — 4 API stats lookup callsites switched from `entity["name"]` to `entity["synthetic_name"]`: - `_flea_to_item` (card stats lookup) - `flea_detail` (`_build_telemetry` + `_load_inner_items_stats_by_parent`) - `flea_skill_detail` (inner detail `parent_plugin` key) - `flea_agent_detail` (inner detail `parent_plugin` key) Tests: - `skill_flea.jsonl` invocation: `flea:flea-skill` → `flea:flea-skill-by-alice` (mirrors what Claude Code writes after phase 1/4 — the suffixed synthetic_name). - `test_flea_skill_attributed_with_empty_parent` assertion: rollup `name` column now carries the synthetic_name. No legacy `agnes-store-bundle` prefix backward compat — clean cut per user direction (dev phase, no production data worth preserving). Verified locally: 53 passed targeted (session_processor_usage + usage_rollups + marketplace_filter_store) + 215 passed/2 skipped broader (store_api + marketplace_api + admin_store_submissions + store_entity_versions). * fix(flea): phase-6 — plugin-level rollup aggregation parity for flea Flea plugin entity cards + detail pages showed 0 invocations even though nested skills had correct rollup rows. Root cause: the plugin-level aggregation pass in `_aggregate_events` was hardcoded to `source='curated'` only: if source != "curated" or not parent: continue if group_by_day: pkey = (day, "curated", "plugin", "", parent) else: pkey = ("curated", "plugin", "", parent) So flea plugin entities never got a synthetic `(source='flea', type='plugin', parent_plugin='', name=)` row aggregating nested invocations. `_load_invocation_stats('flea')` filters `parent_plugin = ''` and returned no row for flea plugin entity cards, so `stats.get(entity["synthetic_name"])` missed and the API exposed 0/0. Triggered by empirical observation on the dev VM — `codex-second-opinion-by-c-marustamyan` plugin showed 0 calls in the listing card while its three inner skills (codex-setup ×3, codex-review ×1, codex-second-opinion ×1) had the expected child rollup rows. Fix: - Extend the guard to `source in ("curated", "flea")`. - Replace the hardcoded `"curated"` in the `pkey` tuple with the loop's `source` variable, so flea aggregation lands as `source= 'flea'` and curated aggregation continues landing as `source='curated'`. API path unchanged — `_load_invocation_stats('flea')` filters `parent_plugin = ''` already picks up the new aggregated row alongside standalone skill/agent rows. Rollup `name` field carries the synthetic_name keyspace; no collision between standalone entity synthetic and plugin entity synthetic (global suffix uniqueness enforced by `_suffixed_already_taken`). `USAGE_PROCESSOR_VERSION` bumped 8 → 9 to force a reprocess pass so historic nested-invocation data fills the new plugin-level rows on the next tick (instead of waiting for the next live invocation). Tests: - New `test_flea_plugin_row_aggregates_children` mirrors the existing `test_curated_plugin_row_aggregates_children`: seeds a flea plugin entity, three nested events (one user invoking two skills, a second user invoking one) → asserts the aggregated plugin row carries count=3, distinct_users=2 (union, not sum), plus the child rows survive alongside. Verified locally: 43 passed (session_processor_usage + usage_rollups) + 82 passed/2 skipped broader (+ marketplace_filter_store + marketplace_api). * refactor(marketplace): phase-7 — unify Details sidebar across detail surfaces Five marketplace detail surfaces (curated plugin, flea plugin, curated inner skill/agent, flea inner skill/agent, flea standalone skill/agent) had drifted on which Details rows they show and what order — the same field landed in different positions, some fields duplicated hero info, and the flea plugin Owner row leaked the kebab-case `owner_username` slug instead of the user's real name. This commit aligns all five surfaces on a single scan order driven by UX priority: identity → life-stage → telemetry → debug-tier Concretely: 1. Curator / Owner (first scan signal — trust) 2. Parent plugin (inner skill/agent only) 3. Released (top-level only — plugins + flea standalone) 4. Last used (recency) 5. Active days (engagement consistency) 6. Version (flea standalone only — content hash) 7. Bundle size (debug-tier) Dropped: - Slug field on plugin detail surfaces (`marketplace_id` for curated, `entity_id` for flea). Pure debug info, never user-relevant; URL already carries it. - Category + Installs on flea standalone skill/agent detail. Category is already shown as a hero badge; install count is in the hero telemetry chip — sidebar duplication added noise. Owner display: - Flea plugin Owner row now reads `d.owner_display` (resolved through `users.name → users.email → owner_username` by `_resolve_owner_display` in `app/api/marketplace.py:1491`) instead of the raw `d.author_name` (which is `owner_username`, the kebab-case slug). API field already populated from phase 2; templates just consume it. - Curated Curator row continues to read `d.author_name` from marketplace-metadata.json; `owner_todo` placeholder behavior preserved. Files: - app/web/templates/marketplace_plugin_detail.html — rewrote the Details render loop (lines 1364-1427 area). Slug row removed, rows reordered, Owner branch reads `d.owner_display`. - app/web/templates/marketplace_item_detail.html — both branches of the Details sidebar (inner skill/agent + flea standalone) re-laid around the same scan order. Telemetry helper unchanged, just repositioned. Category + Installs rows removed from the standalone branch. No new tests — no existing test asserts the precise order of Details rows or references the dropped fields in a sidebar context (grep confirmed). API surface unchanged. Verified locally: 84 passed / 2 skipped on `test_marketplace_api.py` + `test_store_api.py`. * fix(flea): post-review hardening — N+1, v50 UNIQUE, docs, test cleanup Addresses 5 critical findings from PR #342 code review: 1. N+1 query in `_flea_to_item` — owner-display resolution previously ran one `SELECT … FROM users WHERE id = ?` per item in the listing comprehension. Now batched via `_load_users_display` IN-query prefetch; 50 items drops 51 user queries to 2. Regression-guarded by `TestFleaOwnerDisplayBatched` (spies `_resolve_owner_display` and asserts it's not called inside the list path). 2. Misleading comment in `src/marketplace_filter.py` claimed the attribution layer accepts both `agnes-store-bundle` and `flea` prefixes — it doesn't (clean cut per CHANGELOG). Rewrote to match reality. 3. CHANGELOG `[Unreleased]` had two `### Changed` blocks. Merged into one (BREAKING bullet first). 4. New v49→v50 migration adds `UNIQUE INDEX idx_store_entities_synthetic_name`. v49 made `synthetic_name` the canonical attribution key but uniqueness was only app-enforced; v50 promotes the invariant to the DB layer. Migration pre-checks for existing duplicates and raises `RuntimeError` listing them rather than letting `CREATE UNIQUE INDEX` fail mid-way. v48→v49 migration gained an `is_nullable='YES'` guard on its `SET NOT NULL` ALTERs so re-runs on a fully-migrated DB don't trip DuckDB's "cannot alter entry … entries depend on it" block (the new index counts as such an entry). Index is created by the migration only — keeping it out of `_SYSTEM_SCHEMA` preserves fresh-install ordering (CREATE TABLE → v49 ALTERs → v50 CREATE INDEX). 5. Deleted three redundant version-pinned schema asserts whose names lied about their bodies (`test_schema_version_is_42` asserting `== 49`, etc.). Canonical assert lives in `test_db_schema_version.py`, renamed to `test_schema_version_matches_constant`. * fix(db): gate v34→v38 store_entities ALTER COLUMN steps on column state CI on Linux failed `test_v17_to_v18_drops_*` after the v50 UNIQUE INDEX landed. Root cause: those tests open a DB at the full target version, seed fixtures, then reset `schema_version` to 17 and reopen — forcing the ladder to re-run from 17 → current. With the v50 index now in place, DuckDB blocks intermediate `ALTER COLUMN` steps on `store_entities` ("Cannot drop this column: an index depends on a column after it!" / "Cannot alter entry because there are entries that depend on it"), because `synthetic_name` (the indexed column) sits positionally after the columns those steps touch. Fix: convert the three SQL-list migrations that hit store_entities into defensive Python functions: - `_v34_to_v35_migrate` short-circuits when `synthetic_name` already exists (post-v49 shape — the visibility_status rebuild is moot and the DROP COLUMN would be blocked by the index). - `_v35_to_v36_migrate` gates the `visibility_status SET NOT NULL` + `SET DEFAULT` on `is_nullable='YES'` so it's a true no-op when the column is already constrained. - `_v37_to_v38_migrate` gates the `version_no SET NOT NULL` step the same way. Forward-roll path (real installs that never reset schema_version) is unchanged: the gates fire `YES` → ALTERs run. The fix only changes behavior for the "DB is already at v50 shape but version row says 17" scenario the tests construct. --------- Co-authored-by: Minas Arustamyan --- CHANGELOG.md | 156 +++++++- app/api/marketplace.py | 125 ++++++- app/api/my_stack.py | 6 +- app/api/store.py | 110 +++++- app/web/router.py | 10 + .../templates/marketplace_item_detail.html | 90 +++-- .../templates/marketplace_plugin_detail.html | 105 +++--- app/web/templates/store_edit.html | 109 +++++- app/web/templates/store_upload.html | 146 +++++++- cli/commands/admin_store.py | 4 +- services/session_processors/usage_lib.py | 105 ++++-- src/db.py | 275 +++++++++++--- src/marketplace_filter.py | 23 +- src/repositories/store_entities.py | 44 ++- src/repositories/user_store_installs.py | 1 + src/store_naming.py | 58 +++ src/welcome_template.py | 2 +- .../fixtures/sessions/usage/skill_flea.jsonl | 2 +- tests/test_cli_refresh_marketplace.py | 6 +- tests/test_db_schema_version.py | 19 +- tests/test_home_stats.py | 6 - tests/test_marketplace_api.py | 341 +++++++++++++++++- tests/test_marketplace_filter_store.py | 6 +- tests/test_schema_v42_migration.py | 16 +- tests/test_schema_v46_migration.py | 4 - tests/test_schema_v48_to_v49_migration.py | 162 +++++++++ tests/test_schema_v49_to_v50_migration.py | 201 +++++++++++ tests/test_session_processor_usage.py | 16 +- tests/test_store_api.py | 197 +++++++++- tests/test_store_naming.py | 35 ++ tests/test_usage_rollups.py | 61 +++- 31 files changed, 2166 insertions(+), 275 deletions(-) create mode 100644 tests/test_schema_v48_to_v49_migration.py create mode 100644 tests/test_schema_v49_to_v50_migration.py 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
+
+