feat(flea): marketplace refactor — data model, attribution, UI unification (#342)

* 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 `<name>-by-<owner_username>`
  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 <plugin>" meta-row,
  helper "This skill is part of <plugin>" 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
  <author> · N installed · <size>" 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 `<name>-by-<owner_username>` 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:<skill-name>`.
- 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:<synthetic_name>` (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 (`<synthetic>:<inner>`).
- `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=<synth>)`
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 <arustamyan.minas@gmail.com>
This commit is contained in:
minasarustamyan 2026-05-19 02:32:41 +02:00 committed by GitHub
parent e11f03eb60
commit c6c72b9c00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2166 additions and 275 deletions

View file

@ -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:<synthetic_name>` 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 `<name>-by-<owner>` 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 &lt;author&gt; · N installed · &lt;size&gt;". 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
`<name>-by-<owner_username>` 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=<plugin_synth>)` 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:<synthetic_name>` 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
(`/<name>-by-<owner_username>`) 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

View file

@ -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="",

View file

@ -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")),

View file

@ -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/<suffixed>/SKILL.md is the dir name).
the ``flea`` bundle (skills/<suffixed>/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
# <name>-by-<owner> 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/<entity_id>/
# ├── 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)),
},
)

View file

@ -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)

View file

@ -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 %}
<style>
@ -809,12 +809,12 @@
<span class="hwdot red"></span>
<span class="hwdot yellow"></span>
<span class="hwdot green"></span>
<span class="hero-window-label" id="hero-window-label">{{ inner_name or item_name or plugin_name }}</span>
<span class="hero-window-label" id="hero-window-label">{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }}</span>
</div>
<div class="hero-window-body" id="hero-window-body">{{ 'SK' if kind == 'skill' else 'AG' }}</div>
</div>
<div class="meta">
<h1 id="hero-name">{{ inner_name or item_name or plugin_name }}</h1>
<h1 id="hero-name">{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }}</h1>
<div class="hero-tagline" id="hero-tagline" hidden></div>
<div class="meta-row" id="hero-meta-row"></div>
<div class="pills" id="hero-pills">
@ -1048,12 +1048,17 @@
<span class="sep"></span>
<span class="current">${esc(heroTitle)}</span>`;
} else {
// v49 phase-2: drop `d.manifest_name` fallback. manifest_name is the
// suffixed slug (e.g. `xlsx-by-c-marustamyan`) and users explicitly
// didn't want to see it in breadcrumbs. heroTitle (resolved from
// `display_name` which now mirrors `entity.title`) is the
// user-friendly label.
crumbs.innerHTML =
`<a href="/marketplace?tab=flea">Marketplace</a>
<span class="sep"></span>
<a href="/marketplace?tab=flea">Flea Market</a>
<span class="sep"></span>
<span class="current">${esc(d.manifest_name || heroTitle)}</span>`;
<span class="current">${esc(heroTitle)}</span>`;
}
// ── Hero name ──────────────────────────────────────────────────────
@ -1097,7 +1102,7 @@
// Invocation block — lives inside the Description panel so the "how to
// call it" cue sits right under "what it does". Flea entities ship as
// their own plugin (or the agnes-store-bundle), so the manifest_name IS
// their own plugin (or the flea bundle), so the manifest_name IS
// the slash invocation. Curated skills/agents live inside a parent
// plugin, so Claude Code namespaces them as /<plugin>:<inner-name>.
const invBlock = document.getElementById('invocation-block');
@ -1147,8 +1152,12 @@
// * Curated inner (always nested under a parent plugin) → "part of …"
// * Flea inner (skill/agent nested in a flea plugin) → same layout,
// parent link points at the flea plugin detail.
// * Flea standalone (entity is itself the skill/agent) → "by author ·
// N installed · size".
// * Flea standalone (entity is itself the skill/agent) → hidden.
// v49 phase-3: the prior "by <author> · N installed · size" line
// duplicated three values that already live on dedicated surfaces
// — install count in the hero telemetry chip below, owner +
// bundle size in the Details sidebar. Empty meta-row would still
// consume vertical rhythm via .meta-row's margin, so we hide it.
const metaRow = document.getElementById('hero-meta-row');
if (source === 'curated' || innerName) {
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
@ -1162,13 +1171,10 @@
`<span>part of <a href="${parentHref}"><strong>${esc(parentLabel)}</strong></a></span>
<span class="dot">·</span>
<span>by ${author}</span>`;
metaRow.hidden = false;
} else {
const author = d.owner_display || d.author_name || '';
metaRow.innerHTML =
`<span>by <strong>${esc(author)}</strong></span>
<span class="dot">·</span>
<span>${d.install_count || 0} installed</span>
${d.bundle_size != null ? `<span class="dot">·</span><span>${esc(fmtBytes(d.bundle_size))}</span>` : ''}`;
metaRow.innerHTML = '';
metaRow.hidden = true;
}
// Hero action — Curated nested redirects to parent plugin (no install at
@ -1422,14 +1428,17 @@
// ── Details sidebar — skip rows whose value is missing. The
// `owner_todo` placeholder for the Curator row stays as a deliberate
// reminder to wire up curator metadata.
//
// v49 phase-7: unified scan order with plugin detail surface.
// Priority: identity (who) → life-stage (when) → telemetry (how used)
// → debug-tier (version, size). Inner skills/agents pivot on parent
// plugin, so Parent plugin slots immediately after authorship.
const dl = document.getElementById('details-list');
// Activity rows for the sidebar — Invocations + Users live in the
// hero chip now, no need to duplicate. Two derived signals only the
// daily series can give us:
// * Active days — engagement consistency over the 30d window.
// * Last used — recency (answers "is this still being used?").
// Telemetry helper — Last used first (recency, primary decision
// driver), Active days second (engagement consistency over 30d).
// Both rendered only when the item has at least one active day so a
// brand-new skill doesn't show "0 of 30" + "Last used —".
// brand-new skill doesn't show "Last used —" + "0 of 30".
const telemetryRows = (() => {
if (!d.telemetry || !Array.isArray(d.telemetry.daily_series)) return '';
const series = d.telemetry.daily_series;
@ -1439,38 +1448,45 @@
for (let i = series.length - 1; i >= 0; i--) {
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
}
return `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
+ (lastIso
return (lastIso
? `<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
: '');
: '')
+ `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`;
})();
if (source === 'curated' || innerName) {
// Nested skill/agent (curated OR flea-inner): the Details sidebar
// is parent-centric — Parent plugin / Bundle size / activity rows /
// authorship label. The authorship row label tracks the source
// (curated bundles have a Curator, flea bundles have an Owner) so
// the label vocabulary stays consistent with the plugin detail page.
// Nested skill/agent (curated OR flea-inner). Sidebar render order:
// 1. Curator / Owner — first scan signal (trust)
// 2. Parent plugin — where this lives
// 3. Last used — recency
// 4. Active days — engagement
// 5. Bundle size — debug-tier
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
? esc(d.parent_author_name)
: '<span class="todo">owner_todo</span>';
const authorLabel = source === 'curated' ? 'Curator' : 'Owner';
// Type already shown as a badge in the hero — skip the row here.
dl.innerHTML = `
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>
<div class="row"><dt>Parent plugin</dt><dd>${esc(d.parent_display_name || d.manifest_name || pluginName)}</dd></div>
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
${telemetryRows}
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>`;
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
} else {
// Flea standalone skill/agent. Sidebar render order:
// 1. Owner — first scan signal (trust)
// 2. Released — life-stage
// 3. Last used — recency
// 4. Active days — engagement
// 5. Version — debug-tier
// 6. Bundle size — debug-tier
// Drops the previous Category + Installs rows (duplicated hero
// badge + telemetry chip — see v49 phase-7 notes).
const ownerLabel = d.owner_display || d.author_name || '';
// Type already shown as a badge in the hero — skip the row here.
dl.innerHTML = `
${ownerLabel ? `<div class="row"><dt>Owner</dt><dd>${esc(ownerLabel)}</dd></div>` : ''}
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
${d.category ? `<div class="row"><dt>Category</dt><dd>${esc(d.category)}</dd></div>` : ''}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
<div class="row"><dt>Installs</dt><dd>${d.install_count || 0}</dd></div>
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}
${telemetryRows}
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}`;
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
}
// ── Docs section ────────────────────────────────────────────────

View file

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block title %}{{ plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block title %}{{ (entity.title if entity else None) or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
@ -855,7 +855,7 @@
<span class="sep"></span>
<a href="/marketplace?tab={{ 'curated' if source == 'curated' else 'flea' }}" id="crumb-mid">{{ 'Curated Marketplace' if source == 'curated' else 'Flea Market' }}</a>
<span class="sep"></span>
<span id="crumb-name">{{ plugin_name }}</span>
<span id="crumb-name">{{ (entity.title if entity else None) or plugin_name }}</span>
</div>
<div class="hero-head">
<div class="hero-window" id="hero-photo" aria-hidden="true">
@ -863,13 +863,16 @@
<span class="hwdot red"></span>
<span class="hwdot yellow"></span>
<span class="hwdot green"></span>
<span class="hero-window-label" id="hero-window-label">{{ plugin_name }}</span>
<span class="hero-window-label" id="hero-window-label">{{ (entity.title if entity else None) or plugin_name }}</span>
</div>
<div class="hero-window-body" id="hero-window-body">PL</div>
</div>
<div class="meta">
<h1 id="hero-name">{{ plugin_name }}</h1>
<div class="tagline" id="hero-tagline">Loading…</div>
<h1 id="hero-name">{{ (entity.title if entity else None) or plugin_name }}</h1>
<!-- v49 phase-2: hidden by default; JS only un-hides when d.tagline
is truthy. Pre-fix this rendered "Loading…" then collapsed to
empty for entities without a tagline, causing a flicker. -->
<div class="tagline" id="hero-tagline" hidden></div>
<div class="pills" id="hero-pills"></div>
<!-- Telemetry funnel chip — same shape as the listing card so a
user who clicks through from /marketplace sees identical
@ -1042,7 +1045,19 @@
document.title = `${heroTitle} — Marketplace`;
document.getElementById('hero-name').textContent = heroTitle;
document.getElementById('hero-tagline').textContent = d.tagline || d.description || '';
// v49 phase-2: tagline appears in hero only when explicitly set. Pre-fix
// it fell back to `d.description` (verbose long-form text from frontmatter
// / marketplace.json), which read awkwardly under the h1 and pulled the
// hero too tall. Description still renders in the "What it does" panel
// below the hero.
const tagEl = document.getElementById('hero-tagline');
if (d.tagline) {
tagEl.textContent = d.tagline;
tagEl.hidden = false;
} else {
tagEl.textContent = '';
tagEl.hidden = true;
}
// Window titlebar label mirrors the h1 — keeps the mac-window framing
// consistent with whichever name the user actually sees as the title.
const heroWindowLabel = document.getElementById('hero-window-label');
@ -1347,41 +1362,49 @@
}
// ── Details ─────────────────────────────────────────────────────
// Render only rows that have a real value — missing/null/owner_todo
// entries get hidden so the panel stays compact.
// v49 phase-7: unified scan order across plugin + skill/agent detail
// surfaces. Priority: identity (who) → life-stage (when) → telemetry
// (how used) → debug-tier (size). Slug field dropped — debug info,
// not user-facing.
//
// Render order:
// 1. Curator / Owner — first scan signal (trust)
// 2. Released — life-stage
// 3. Last used — recency (higher priority than Active days)
// 4. Active days — engagement consistency over 30d
// 5. Bundle size — debug-tier
//
// Each row is conditional on data presence so the panel stays compact.
const detailRows = [];
const slugVal = d.source === 'curated' ? d.marketplace_id : d.entity_id;
if (slugVal) {
detailRows.push(`<div class="row"><dt>Slug</dt><dd class="mono">${esc(slugVal)}</dd></div>`);
// 1. Curator/Owner. Curated → d.author_name (with owner_todo placeholder
// surfaced as visible reminder). Flea → d.owner_display (fullname from
// users.name → users.email → owner_username chain, populated by
// _resolve_owner_display in app/api/marketplace.py).
if (d.source === 'curated') {
if (d.author_name && d.author_name !== 'owner_todo') {
detailRows.push(`<div class="row"><dt>Curator</dt><dd>${esc(d.author_name)}</dd></div>`);
} else {
detailRows.push(`<div class="row"><dt>Curator</dt><dd><span class="todo">owner_todo</span></dd></div>`);
}
} else if (d.owner_display) {
detailRows.push(`<div class="row"><dt>Owner</dt><dd>${esc(d.owner_display)}</dd></div>`);
}
// 2. Released.
if (d.released_at) {
detailRows.push(`<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>`);
}
if (d.bundle_size != null) {
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
}
// Activity sidebar rows — Invocations + Users were moved into the
// hero chip above (no need to duplicate the same figures twice on
// one page). The sidebar now carries two *derived* signals from the
// daily series that the hero doesn't expose:
// * Active days — engagement consistency over the 30d window.
// "Active on 18 of 30 days" reads very differently from
// "Active on 2 of 30 days" even when the raw call count is
// similar; helps spot bursty vs. steady usage.
// * Last used — recency, answering "is it still alive?". A
// plugin with 12 installed users but last call 30 days ago is
// stale; the hero chip can't surface that alone.
// 3-4. Telemetry-derived rows. Invocations + Users live in the hero
// chip; sidebar carries two derived signals only the daily series can
// give us. Last used first (recency), Active days second (consistency).
if (d.telemetry && Array.isArray(d.telemetry.daily_series)) {
const series = d.telemetry.daily_series;
const activeDays = series.filter(p => (p.invocations || 0) > 0).length;
if (activeDays > 0) {
detailRows.push(
`<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
);
// Last day with at least one invocation. Series is day-ascending
// (per _load_plugin_daily_series), so the final non-zero entry is
// the latest active day. Render relative ("2 days ago" / "Active
// today") via the same fmtRelative helper used elsewhere.
// Last day with at least one invocation. Series is day-ascending,
// so the final non-zero entry is the latest active day.
let lastIso = null;
for (let i = series.length - 1; i >= 0; i--) {
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
@ -1391,19 +1414,17 @@
`<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
);
}
detailRows.push(
`<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
);
}
}
// Authorship row label tracks the source: curated bundles have a
// curator (the marketplace operator), flea bundles have an owner
// (the analyst who uploaded). When `author_name` is the `owner_todo`
// placeholder the curated branch surfaces it visibly as a reminder
// to wire up curator metadata; flea falls through silently.
const authorLabel = d.source === 'curated' ? 'Curator' : 'Owner';
if (d.author_name && d.author_name !== 'owner_todo') {
detailRows.push(`<div class="row"><dt>${authorLabel}</dt><dd>${esc(d.author_name)}</dd></div>`);
} else if (d.source === 'curated') {
detailRows.push(`<div class="row"><dt>Curator</dt><dd><span class="todo">owner_todo</span></dd></div>`);
// 5. Bundle size.
if (d.bundle_size != null) {
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
}
const detailsEl = document.getElementById('details-list');
if (detailRows.length) {
detailsEl.innerHTML = detailRows.join('');

View file

@ -61,6 +61,29 @@
.banner[hidden] { display: none !important; }
.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.banner > span { white-space: pre-wrap; }
/* v49: matches the synthetic-name preview styling on store_upload.html
so upload + edit forms read identically. */
.name-row {
display: flex; align-items: stretch; gap: 10px; flex-wrap: wrap;
}
.name-row input[type=text] {
flex: 0 1 320px; min-width: 220px;
}
.invocation-preview {
flex: 1 1 320px; min-width: 0;
display: flex; align-items: center;
background: #1e1e2e; color: #cdd6f4;
border-radius: 6px; padding: 0 14px;
font-family: var(--font-mono, monospace); font-size: 13px;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.invocation-preview .prompt {
color: #a6e3a1; user-select: none; margin-right: 2px;
}
.invocation-preview #synthetic-text {
overflow: hidden; text-overflow: ellipsis;
}
</style>
<div class="page-shell">
@ -85,11 +108,25 @@
</div>
<form id="edit-form">
<div class="field">
<label class="field-label" for="f-title">Title</label>
<input id="f-title" name="title" type="text" maxlength="100" required
value="{{ entity.title or '' }}"
data-user-edited="{{ 'true' if entity.title else 'false' }}"
{% if pending_sub %}disabled{% endif %}>
<div class="field-help">Human-friendly name shown on marketplace cards.</div>
</div>
<div class="field">
<label class="field-label" for="f-name">Display name</label>
<div class="name-row">
<input id="f-name" name="name" type="text" value="{{ entity.name | store_display_name }}"
pattern="^[a-z][a-z0-9-]{0,63}$"
{% if pending_sub %}disabled{% endif %}>
<div class="invocation-preview" id="synthetic-preview" aria-live="polite">
<span class="prompt">/</span><span id="synthetic-text">{{ entity.synthetic_name or (entity.name ~ '-by-' ~ entity.owner_username) }}</span>
</div>
</div>
<div class="field-help warn">
⚠ Changing the name renames the plugin slug for existing
installers. They'll see the plugin renamed on their next sync
@ -97,6 +134,15 @@
</div>
</div>
<div class="field">
<label class="field-label" for="f-tagline">Short description <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<input id="f-tagline" name="tagline" type="text" maxlength="200"
value="{{ entity.tagline or '' }}"
placeholder="One-line summary shown alongside the entity name"
{% if pending_sub %}disabled{% endif %}>
<div id="tagline-counter" class="field-help" style="font-family: var(--font-mono); font-size: 12px;">0 / 200 max</div>
</div>
<div class="field">
<label class="field-label" for="f-description">Description</label>
<textarea id="f-description" name="description" maxlength="1000"
@ -246,6 +292,9 @@ function humanizeError(detail) {
if (code === 'conflict_owner_name') return 'You already have a plugin with that name.';
if (code === 'conflict_global_suffix') return 'That name conflicts with another user\'s plugin slug.';
if (code === 'invalid_name_format') return 'Name must be lowercase letters / digits / hyphens, starting with a letter.';
if (code === 'title_required') return 'Title is required.';
if (code === 'title_too_long') return 'Title is too long — max 100 characters.';
if (code === 'tagline_too_long') return 'Short description is too long — max 200 characters.';
if (code) return 'Save failed: ' + code;
try { return 'Save failed: ' + JSON.stringify(detail); } catch (_) { return 'Save failed.'; }
}
@ -287,6 +336,53 @@ wireDropZone(
(f) => f.size > 5 * 1024 * 1024 ? 'Photo too large (max 5 MB).' : null,
);
// v49 phase-1 — Title, Tagline, synthetic preview wiring (mirrors
// store_upload.html). Acronym dict + owner injected via template ctx.
const TITLE_ACRONYMS = {{ title_acronyms|tojson if title_acronyms else "{}" }};
const OWNER_USERNAME = {{ owner_username|tojson if owner_username else '""' }};
const titleInput = document.getElementById('f-title');
const taglineInput = document.getElementById('f-tagline');
const taglineCounter = document.getElementById('tagline-counter');
const fNameInput = document.getElementById('f-name');
const syntheticText = document.getElementById('synthetic-text');
function humanizeName(name) {
if (!name) return '';
const tokens = String(name).split('-').filter(Boolean);
return tokens.map(tok => {
const canonical = TITLE_ACRONYMS[tok.toLowerCase()];
if (canonical !== undefined) return canonical;
return tok.charAt(0).toUpperCase() + tok.slice(1).toLowerCase();
}).join(' ');
}
function updateSyntheticPreview() {
const name = (fNameInput.value || '').trim();
if (!name) {
syntheticText.textContent = 'your-name-by-' + (OWNER_USERNAME || 'you');
} else {
syntheticText.textContent = name + '-by-' + (OWNER_USERNAME || 'you');
}
}
function maybeAutoFillTitle() {
if (titleInput.dataset.userEdited === 'true') return;
const name = (fNameInput.value || '').trim();
titleInput.value = humanizeName(name);
}
function updateTaglineCounter() {
const len = (taglineInput.value || '').length;
taglineCounter.textContent = `${len} / 200 max`;
}
titleInput.addEventListener('input', () => {
titleInput.dataset.userEdited = titleInput.value.trim() ? 'true' : 'false';
});
fNameInput.addEventListener('input', () => {
updateSyntheticPreview();
maybeAutoFillTitle();
});
taglineInput.addEventListener('input', updateTaglineCounter);
updateSyntheticPreview();
updateTaglineCounter();
document.getElementById('edit-form').addEventListener('submit', async (e) => {
e.preventDefault();
clearBanner();
@ -300,6 +396,13 @@ document.getElementById('edit-form').addEventListener('submit', async (e) => {
fd.append('description', document.getElementById('f-description').value);
fd.append('category', document.getElementById('f-category').value);
fd.append('video_url', document.getElementById('f-video').value);
// v49: title is required when present; always send so server can
// detect "no change" (same value) vs "user-cleared". Tagline sends
// empty string to clear.
const titleVal = titleInput.value.trim();
if (!titleVal) { showError('Title is required.'); saveBtn.disabled = false; saveBtn.textContent = 'Save'; return; }
fd.append('title', titleVal);
fd.append('tagline', taglineInput.value.trim());
const zip = document.getElementById('zip').files[0];
if (zip) fd.append('file', zip);
const photo = document.getElementById('photo').files[0];

View file

@ -210,6 +210,47 @@
}
.desc-counter.ok { color: #16a34a; }
.desc-counter.warn { color: #b45309; }
/* v49 phase-1: name row pairs the editable kebab-case input with a
read-only dark "what Claude Code will see" preview. Visual language
matches the dark .invocation block on marketplace_item_detail.html
(Catppuccin Mocha #1e1e2e bg, #cdd6f4 fg, #a6e3a1 prompt) so the
two surfaces read as the same concept. No copy button here — this
is a live preview, not an artifact to copy. */
.name-row {
display: flex;
align-items: stretch;
gap: 10px;
flex-wrap: wrap;
}
.name-row input[type="text"] {
flex: 0 1 320px;
min-width: 220px;
}
.invocation-preview {
flex: 1 1 320px;
min-width: 0;
display: flex;
align-items: center;
background: #1e1e2e;
color: #cdd6f4;
border-radius: 8px;
padding: 0 14px;
font-family: var(--font-mono);
font-size: 13px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.invocation-preview .prompt {
color: #a6e3a1;
user-select: none;
margin-right: 2px;
}
.invocation-preview #synthetic-text {
overflow: hidden;
text-overflow: ellipsis;
}
.guidelines {
margin-bottom: 14px;
background: var(--background, #f9fafb);
@ -465,10 +506,31 @@
</div>
</div>
<div class="field">
<label class="field-label" for="title">Title</label>
<input type="text" id="title" maxlength="100" required
placeholder="My Awesome Skill"
data-user-edited="false">
<div class="field-hint">Human-friendly name shown on marketplace cards. Pre-filled from Name, edit freely.</div>
</div>
<div class="field">
<label class="field-label" for="name">Name</label>
<div class="name-row">
<input type="text" id="name" required placeholder="my-awesome-skill">
<div class="field-hint">Lowercase letters, digits, hyphens. Max 64 characters.</div>
<div class="invocation-preview" id="synthetic-preview" aria-live="polite">
<span class="prompt">/</span><span id="synthetic-text">your-name-by-{{ owner_username|default("you") }}</span>
</div>
</div>
<div class="field-hint">Lowercase letters, digits, hyphens. Max 64 characters. Final invocation shown to the right.</div>
</div>
<div class="field">
<label class="field-label" for="tagline">Short description <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<input type="text" id="tagline" maxlength="200"
placeholder="One-line summary shown alongside the entity name">
<div id="tagline-counter" class="desc-counter">0 / 200 max</div>
<div class="field-hint">Max 200 characters. Renders next to the title on marketplace listings.</div>
</div>
<div class="field">
@ -559,10 +621,73 @@ const photoLabel = document.getElementById('photo-label');
const addDocBtn = document.getElementById('add-doc-btn');
const docInput = document.getElementById('doc-input');
const docList = document.getElementById('doc-list');
// v49 phase-1 — Title + Tagline inputs and the read-only synthetic preview.
const titleInput = document.getElementById('title');
const taglineInput = document.getElementById('tagline');
const taglineCounter = document.getElementById('tagline-counter');
const nameInput = document.getElementById('name');
const syntheticText = document.getElementById('synthetic-text');
// Acronym dict + owner username injected from the route handler so JS
// humanize stays in sync with the Python TITLE_ACRONYMS source.
const TITLE_ACRONYMS = {{ title_acronyms|tojson if title_acronyms else "{}" }};
const OWNER_USERNAME = {{ owner_username|tojson if owner_username else '""' }};
let zipFile = null;
let docs = [];
// Replicates src/store_naming.py:humanize_name(). Split on `-`, drop empty
// tokens, replace each token with its canonical acronym form (case-insensitive
// lookup) or Title Case it. Join with spaces.
function humanizeName(name) {
if (!name) return '';
const tokens = String(name).split('-').filter(Boolean);
return tokens.map(tok => {
const canonical = TITLE_ACRONYMS[tok.toLowerCase()];
if (canonical !== undefined) return canonical;
return tok.charAt(0).toUpperCase() + tok.slice(1).toLowerCase();
}).join(' ');
}
// Updates the read-only `/<name>-by-<owner>` preview as the user types
// in the Name field. Empty name shows a placeholder.
function updateSyntheticPreview() {
const name = (nameInput.value || '').trim();
if (!name) {
syntheticText.textContent = 'your-name-by-' + (OWNER_USERNAME || 'you');
} else {
syntheticText.textContent = name + '-by-' + (OWNER_USERNAME || 'you');
}
}
// Auto-fill Title from Name unless the user has manually edited Title.
// We track manual edits via a `data-user-edited` flag on the input so
// flipping back to auto-fill is possible by clearing the title.
function maybeAutoFillTitle() {
if (titleInput.dataset.userEdited === 'true') return;
const name = (nameInput.value || '').trim();
titleInput.value = humanizeName(name);
}
titleInput.addEventListener('input', () => {
titleInput.dataset.userEdited = titleInput.value.trim() ? 'true' : 'false';
});
nameInput.addEventListener('input', () => {
updateSyntheticPreview();
maybeAutoFillTitle();
});
// Tagline counter (mirror desc-counter pattern).
function updateTaglineCounter() {
const len = (taglineInput.value || '').length;
taglineCounter.textContent = `${len} / 200 max`;
taglineCounter.classList.toggle('warn', len > 200);
}
taglineInput.addEventListener('input', updateTaglineCounter);
// Initialize on load.
updateSyntheticPreview();
updateTaglineCounter();
// Server returns short machine codes in `detail`. Map them to human-friendly
// sentences here. Any unknown code falls back to a generic prefix + the raw
// code so debugging stays possible without leaking jargon.
@ -595,6 +720,9 @@ const ERROR_MESSAGES = {
'Couldn\'t derive a username from your email. Contact your administrator.',
conflict_owner_name:
'You already have a Store entity with this name. Each owner needs unique names — pick a different one or delete the existing entity first.',
title_required: 'Title is required.',
title_too_long: 'Title is too long — max 100 characters.',
tagline_too_long: 'Short description is too long — max 200 characters.',
// Step 2 — photo
photo_unsupported_format: 'Photo must be JPG, PNG, or WebP.',
@ -1052,7 +1180,14 @@ nextBtn.addEventListener('click', async () => {
const preview = await res.json();
document.getElementById('name').value = preview.name || '';
document.getElementById('description').value = preview.description || '';
// v49: pre-fill Title from server-computed humanize unless the user
// already touched the field (e.g. came back via Back). Keep the
// user-edited flag stable so re-entering step 2 doesn't clobber.
if (titleInput.dataset.userEdited !== 'true') {
titleInput.value = preview.title || humanizeName(preview.name || '');
}
updateDescCounter();
updateSyntheticPreview();
renderComponents(preview.components || []);
showStep(2);
} catch (err) {
@ -1070,6 +1205,9 @@ finishBtn.addEventListener('click', async () => {
clearBanner();
const name = document.getElementById('name').value.trim();
if (!name) { showError('Name is required.'); return; }
// v49: Title is required (NOT NULL in DB). Auto-fill should have set
// it; this guards manual clears.
if (!titleInput.value.trim()) { showError('Title is required.'); return; }
function isContentBlock(detail) {
if (!detail || !detail.checks) return false;
@ -1091,6 +1229,10 @@ finishBtn.addEventListener('click', async () => {
fd.append('type', type);
fd.append('name', name);
fd.append('description', document.getElementById('description').value);
// v49: title required, tagline optional.
fd.append('title', titleInput.value.trim());
const tag = taglineInput.value.trim();
if (tag) fd.append('tagline', tag);
const cat = document.getElementById('category').value;
if (cat) fd.append('category', cat);
const vurl = document.getElementById('video_url').value.trim();

View file

@ -35,8 +35,8 @@ def pull_bundle(
owner: Optional[str] = typer.Option(None, "--owner", help="Filter by owner user_id"),
search: Optional[str] = typer.Option(None, "--search", "-q"),
out: Path = typer.Option(
Path("agnes-store-bundle.zip"), "-o", "--out",
help="Where to save the ZIP (default: ./agnes-store-bundle.zip)",
Path("flea.zip"), "-o", "--out",
help="Where to save the ZIP (default: ./flea.zip)",
),
unpack: Optional[Path] = typer.Option(
None, "--unpack",

View file

@ -40,17 +40,24 @@ from dataclasses import dataclass
from datetime import datetime, timezone, timedelta
from typing import Iterator
# v6: MarketplaceItemLookup now resolves <flea_plugin>:<inner> prefixes so
# skills/agents nested inside a flea plugin bundle attribute to source='flea'
# with parent_plugin=<plugin name> (same shape as curated nested attribution).
# Pre-v6 these landed as ('builtin', '', None) and never flowed into the
# rollup tables. Bump forces re-attribution on the next reprocess tick.
# v9: phase-6 plugin-level rollup parity for flea. `_aggregate_events`
# now produces synthetic (source='flea', type='plugin', parent_plugin='',
# name=<plugin_synth>) rows aggregating nested skill/agent invocations,
# mirroring the curated path. Without this, flea plugin entity cards +
# detail telemetry chips read 0 from `_load_invocation_stats` (which
# filters `parent_plugin = ''` for flea) even though nested children
# had correct rollup rows. Bump forces a re-aggregation pass so historic
# nested-invocation data fills the new plugin-level rows.
# (v8: phase-5 attribution keyspace fix + phase-4 bundle rename. Lookup
# tables key by `store_entities.synthetic_name` instead of `name`;
# `_attribute_event` gained the flea-plugin-nested branch so nested
# skills inside flea plugins flow into rollup tables.)
# (v5: v46 marketplace-telemetry refactor swapped AttributionLookup for
# MarketplaceItemLookup. Identifier prefix (`<plugin>:<local>`) now drives
# attribution and usage_events.source / ref_id are populated per-event from
# the live marketplace_plugins + store_entities tables.)
# (v4: #293 user_id column; v3: #303 <command-name> slash extraction.)
USAGE_PROCESSOR_VERSION = 6
USAGE_PROCESSOR_VERSION = 9
# Claude Code wraps user-typed slash invocations as
# <command-name>/<name></command-name> inside the user message content
@ -243,8 +250,8 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
# Synthetic plugin name Agnes uses to bundle flea-market store entities into
# a single Claude Code marketplace surface. Skill/agent/command identifiers
# from flea entities arrive as `agnes-store-bundle:<entity-name>` in the JSONL.
FLEA_BUNDLE_PREFIX = "agnes-store-bundle"
# from flea entities arrive as `flea:<synthetic_name>` in the JSONL.
FLEA_BUNDLE_PREFIX = "flea"
class MarketplaceItemLookup:
@ -254,7 +261,7 @@ class MarketplaceItemLookup:
Claude Code writes plugin-defined skill/agent/command identifiers in the
JSONL as ``<plugin_name>:<local_name>`` (e.g. ``grpn:design``). The prefix
is the *plugin* name (curated plugin name, or the synthetic
``agnes-store-bundle`` for flea entities); the local part is the
``flea`` for flea entities); the local part is the
skill/agent/command name relative to that plugin. Identifiers without
a ``:`` are either built-in tools (``Bash``, ``Read``, ) or flat slash
commands (``/exit``) neither participates in marketplace telemetry.
@ -274,20 +281,26 @@ class MarketplaceItemLookup:
"SELECT DISTINCT name FROM marketplace_plugins"
).fetchall()
}
# Flea entities are looked up by name; we also need their type to
# determine whether the invocation should land as skill, agent or
# plugin in the rollup tables.
# v49 phase-5: lookup table keyed by `synthetic_name` (the
# `<name>-by-<owner>` slug baked into the served plugin tree). Claude
# Code writes the local part of a flea invocation as that synthetic
# name (`flea:xlsx-by-c-marustamyan`), so matching against `name`
# (un-suffixed) never landed. Type comes along so the rollup writer
# knows whether to record the invocation as skill / agent / plugin.
self._flea_entities: dict[str, str] = {
row[0]: row[1] for row in conn.execute(
"SELECT name, type FROM store_entities WHERE visibility_status='approved'"
"SELECT synthetic_name, type FROM store_entities WHERE visibility_status='approved'"
).fetchall()
}
# Flea PLUGIN entities can be matched as a prefix too — `<plugin>:<inner>`
# invocations of a skill / agent that lives inside a flea plugin bundle
# land here, mirroring the curated nested attribution path. Standalone
# flea entities still flow through the FLEA_BUNDLE_PREFIX branch.
# Set carries synthetic_names because that's the plugin slug Claude
# Code resolves at install time (v49 phase-4: `data["name"] = suffixed`
# in `_bake_plugin_tree` for type='plugin' entities).
self._flea_plugins: set[str] = {
name for name, ent_type in self._flea_entities.items()
synthetic for synthetic, ent_type in self._flea_entities.items()
if ent_type == "plugin"
}
@ -480,14 +493,21 @@ def _identifier_split(skill_name, subagent_type, command_name, event_type):
def _attribute_event(curated_plugins: set[str], flea_entities: dict[str, str],
flea_plugins: set[str],
skill_name, subagent_type, command_name, event_type):
"""Resolve one event to (source, type, parent_plugin, name).
Returns None when the event doesn't belong in marketplace rollups
(built-in tool, flat slash command, unknown plugin prefix).
Lookup tables (curated_plugins, flea_entities) are passed in so the
caller can preload once and reuse across thousands of events.
Lookup tables (curated_plugins, flea_entities, flea_plugins) are passed
in so the caller can preload once and reuse across thousands of events.
Mirrors the four branches `MarketplaceItemLookup.resolve()` walks:
1. ``flea:<synthetic>`` standalone flea skill/agent/plugin
2. ``<curated_plugin>:<inner>`` nested skill/agent of a curated plugin
3. ``<flea_plugin>:<inner>`` nested skill/agent of a flea plugin
4. anything else None (filtered out of rollups)
"""
prefix, local, default_type = _identifier_split(skill_name, subagent_type, command_name, event_type)
if prefix is None:
@ -499,10 +519,19 @@ def _attribute_event(curated_plugins: set[str], flea_entities: dict[str, str],
return ("flea", ent_type, "", local)
if prefix in curated_plugins:
return ("curated", default_type, prefix, local)
if prefix in flea_plugins:
# v49 phase-5: nested skill/agent inside a flea plugin bundle.
# Same shape as curated nested attribution (source='flea',
# parent_plugin=<synthetic plugin name>, name=<inner frontmatter
# name>). Without this branch the rollup builder silently dropped
# inner-item invocations even though MarketplaceItemLookup.resolve()
# — used by the live writer — handled them since v6.
return ("flea", default_type, prefix, local)
return None
def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_day: bool):
def _aggregate_events(events_rows, curated_plugins, flea_entities,
flea_plugins, *, group_by_day: bool):
"""Walk raw event rows and produce aggregated buckets.
``events_rows`` shape: (day, user_id, is_error, skill_name, subagent_type,
@ -517,7 +546,8 @@ def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_d
leaf: dict[tuple, dict] = {}
for row in events_rows:
day, uid, is_err, sk, sa, cm, etype = row
attributed = _attribute_event(curated_plugins, flea_entities, sk, sa, cm, etype)
attributed = _attribute_event(curated_plugins, flea_entities, flea_plugins,
sk, sa, cm, etype)
if attributed is None:
continue
source, type_, parent, name = attributed
@ -532,9 +562,14 @@ def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_d
if is_err:
b["errors"] += 1
# Plugin-level rollup: curated invocations get a parent row, summing the
# children. distinct_users at plugin level recomputed across child users
# so a user counted in two skills of the same plugin doesn't double-count.
# Plugin-level rollup: curated AND flea invocations get a parent row,
# summing the children. distinct_users at plugin level recomputed across
# child users so a user counted in two skills of the same plugin doesn't
# double-count. v49 phase-6: extended to flea (was curated-only); without
# this, flea plugin entities never got an aggregated row, so the
# parent_plugin='' filter in `_load_invocation_stats` returned no rows
# for plugin cards / detail telemetry chips even though nested children
# were attributed correctly.
plugin_bucket: dict[tuple, dict] = {}
for key, vals in leaf.items():
if group_by_day:
@ -542,12 +577,12 @@ def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_d
else:
day = None
source, type_, parent, name = key
if source != "curated" or not parent:
if source not in ("curated", "flea") or not parent:
continue
if group_by_day:
pkey = (day, "curated", "plugin", "", parent)
pkey = (day, source, "plugin", "", parent)
else:
pkey = ("curated", "plugin", "", parent)
pkey = (source, "plugin", "", parent)
pb = plugin_bucket.setdefault(pkey, {"count": 0, "users": set(), "errors": 0})
pb["count"] += vals["count"]
pb["users"] |= vals["users"]
@ -613,14 +648,21 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
since_day = (datetime.now(timezone.utc) - timedelta(days=7)).date()
# Preload lookup tables once — reused across daily + 7d + 30d rebuilds.
# v49 phase-5: dict keyed by `synthetic_name` (matches the JSONL invocation
# local-part) instead of `name`. `flea_plugins` set drives the
# `<plugin>:<inner>` nested-attribution branch in `_attribute_event`.
curated_plugins = {
r[0] for r in conn.execute("SELECT DISTINCT name FROM marketplace_plugins").fetchall()
}
flea_entities = {
r[0]: r[1] for r in conn.execute(
"SELECT name, type FROM store_entities WHERE visibility_status='approved'"
"SELECT synthetic_name, type FROM store_entities WHERE visibility_status='approved'"
).fetchall()
}
flea_plugins = {
synthetic for synthetic, ent_type in flea_entities.items()
if ent_type == "plugin"
}
do_30d = force_30d or _last_30d_due(conn)
@ -666,7 +708,8 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
[since_day],
).fetchall()
daily_buckets = _aggregate_events(
daily_events, curated_plugins, flea_entities, group_by_day=True
daily_events, curated_plugins, flea_entities, flea_plugins,
group_by_day=True,
)
conn.execute("DELETE FROM usage_marketplace_item_daily WHERE day >= ?", [since_day])
if daily_buckets:
@ -685,14 +728,14 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
# ---- New: usage_marketplace_item_window period_label='last_7d' (full) ----
cutoff_7d = (datetime.now(timezone.utc) - timedelta(days=7)).date()
_rebuild_window(
conn, "last_7d", cutoff_7d, curated_plugins, flea_entities,
conn, "last_7d", cutoff_7d, curated_plugins, flea_entities, flea_plugins,
)
# ---- New: usage_marketplace_item_window period_label='last_30d' (hourly) ----
if do_30d:
cutoff_30d = (datetime.now(timezone.utc) - timedelta(days=30)).date()
_rebuild_window(
conn, "last_30d", cutoff_30d, curated_plugins, flea_entities,
conn, "last_30d", cutoff_30d, curated_plugins, flea_entities, flea_plugins,
)
_mark_last_30d_refreshed(conn)
@ -705,7 +748,8 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
raise
def _rebuild_window(conn, period_label: str, cutoff_day, curated_plugins, flea_entities) -> None:
def _rebuild_window(conn, period_label: str, cutoff_day, curated_plugins,
flea_entities, flea_plugins) -> None:
"""Full DELETE+INSERT of one period_label in usage_marketplace_item_window.
Caller wraps the call in a BEGIN/COMMIT transaction along with the
@ -727,7 +771,8 @@ def _rebuild_window(conn, period_label: str, cutoff_day, curated_plugins, flea_e
[cutoff_day],
).fetchall()
buckets = _aggregate_events(
events, curated_plugins, flea_entities, group_by_day=False
events, curated_plugins, flea_entities, flea_plugins,
group_by_day=False,
)
conn.execute(
"DELETE FROM usage_marketplace_item_window WHERE period_label = ?",

235
src/db.py
View file

@ -40,7 +40,7 @@ def _maybe_instrument(con, db_tag: str):
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
SCHEMA_VERSION = 48
SCHEMA_VERSION = 50
_SYSTEM_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_version (
@ -576,6 +576,19 @@ CREATE TABLE IF NOT EXISTS store_entities (
-- StoreEntitiesRepository.append_version + restore endpoint.
version_no INTEGER NOT NULL DEFAULT 1,
version_history JSON DEFAULT '[]',
-- v49: phase-1 Flea refactor adds three user-facing metadata columns.
-- `title` is a humanized display name (acronym-aware), shown on web
-- surfaces instead of the kebab-case `name`. `tagline` is an optional
-- 200-char short description for card UI (long-form lives in
-- `description`). `synthetic_name` is the deterministic
-- `<name>-by-<owner_username>` value baked into served bundles
-- stored as a column so attribution + uniqueness checks can target a
-- single source of truth instead of recomputing the concat on every
-- query. Phase 1 only populates these; downstream surfaces (cards,
-- detail pages, Claude Code propagation) consume them in later phases.
title VARCHAR NOT NULL,
tagline VARCHAR,
synthetic_name VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp,
UNIQUE (owner_user_id, name)
@ -659,6 +672,12 @@ CREATE TABLE IF NOT EXISTS store_submissions (
CREATE INDEX IF NOT EXISTS idx_store_submissions_status ON store_submissions(status);
CREATE INDEX IF NOT EXISTS idx_store_submissions_entity ON store_submissions(entity_id);
-- NOTE: the v50 UNIQUE INDEX on store_entities.synthetic_name is created
-- by ``_v49_to_v50_migrate``, not here. Reason: ``_v48_to_v49_migrate``
-- runs ``ALTER TABLE store_entities ALTER COLUMN SET NOT NULL`` which
-- DuckDB blocks when an index already references the table. Fresh-install
-- ordering is therefore: CREATE TABLE (no index) v49 migrate (no-op
-- ALTERs on empty table) v50 migrate (CREATE UNIQUE INDEX).
-- NOTE: no created_at index. DuckDB 1.x has a bug where
-- `ORDER BY <indexed col> DESC LIMIT N` short-returns on small tables
-- (reproduced with N=2 against 3 rows during /admin/store/submissions
@ -2547,6 +2566,21 @@ def _v34_to_v35_migrate(conn: duckdb.DuckDBPyConnection) -> None:
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP")
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS archived_by VARCHAR")
# If the table is already at a post-v49 shape (synthetic_name column
# present from phase-1 Flea refactor), the v35 visibility_status rebuild
# has effectively been done long ago AND the v50 UNIQUE INDEX on
# synthetic_name now blocks `DROP COLUMN visibility_status` (DuckDB
# forbids dropping a column when an index references a column after it
# positionally). Short-circuit so re-runs of the ladder on a fully
# migrated DB (e.g. a test that resets schema_version backwards) stay
# idempotent.
post_v49 = conn.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = 'store_entities' AND column_name = 'synthetic_name'"
).fetchone()
if post_v49:
return
cols = {
r[0]
for r in conn.execute(
@ -2602,13 +2636,32 @@ def _v34_to_v35_migrate(conn: duckdb.DuckDBPyConnection) -> None:
# whose schema_version row says 36 but whose users table is missing
# `onboarded` — the only consequence-free recovery is an idempotent
# ADD IF NOT EXISTS at the v36 step.
_V35_TO_V36_MIGRATIONS = [
"UPDATE store_entities SET visibility_status = 'pending' WHERE visibility_status IS NULL",
"ALTER TABLE store_entities ALTER COLUMN visibility_status SET NOT NULL",
"ALTER TABLE store_entities ALTER COLUMN visibility_status SET DEFAULT 'pending'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarded BOOLEAN DEFAULT FALSE",
"UPDATE users SET onboarded = FALSE WHERE onboarded IS NULL",
]
def _v35_to_v36_migrate(conn: duckdb.DuckDBPyConnection) -> None:
"""Idempotent v35→v36. Gates the ALTER COLUMN steps on current
nullability once v50 creates the UNIQUE INDEX on store_entities,
DuckDB blocks ALTER COLUMN against the table (the index references
a column "after" visibility_status positionally), so a redundant
SET NOT NULL on an already-NOT-NULL column would explode."""
conn.execute(
"UPDATE store_entities SET visibility_status = 'pending' "
"WHERE visibility_status IS NULL"
)
nullable = conn.execute(
"SELECT is_nullable FROM information_schema.columns "
"WHERE table_name = 'store_entities' AND column_name = 'visibility_status'"
).fetchone()
if nullable and nullable[0] == "YES":
conn.execute(
"ALTER TABLE store_entities ALTER COLUMN visibility_status SET NOT NULL"
)
conn.execute(
"ALTER TABLE store_entities ALTER COLUMN visibility_status "
"SET DEFAULT 'pending'"
)
conn.execute(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarded BOOLEAN DEFAULT FALSE"
)
conn.execute("UPDATE users SET onboarded = FALSE WHERE onboarded IS NULL")
# v37→v38: flea-market entity edit feature with version history.
@ -2635,25 +2688,37 @@ _V35_TO_V36_MIGRATIONS = [
# created_at backfilled from the entity row; submission_id is best-
# effort (we look up the most recent submission_id for the entity_id
# if any exists, else NULL).
_V37_TO_V38_MIGRATIONS = [
def _v37_to_v38_migrate(conn: duckdb.DuckDBPyConnection) -> None:
"""Idempotent v37→v38. Gates the ``version_no SET NOT NULL`` step on
current nullability see ``_v35_to_v36_migrate`` for why."""
# Defensive: minimal partial-state DBs from earlier migrations may
# be missing columns the backfill UPDATE below references. Add
# them idempotently first. Real post-v29 DBs already have these;
# this is a no-op there. Keeps the recovery path through
# `tests/test_db_schema_version.py::test_v32_db_with_partial_v35_recovers_through_full_ladder`
# intact when walking from v32 fixture forward.
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version VARCHAR",
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS file_size BIGINT",
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS created_at TIMESTAMP",
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version VARCHAR")
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS file_size BIGINT")
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS created_at TIMESTAMP")
# DuckDB ALTER doesn't accept "NOT NULL DEFAULT" together — split:
# ADD nullable + DEFAULT, backfill nulls, then SET NOT NULL.
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_no INTEGER DEFAULT 1",
"UPDATE store_entities SET version_no = 1 WHERE version_no IS NULL",
"ALTER TABLE store_entities ALTER COLUMN version_no SET NOT NULL",
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_history JSON DEFAULT '[]'",
conn.execute(
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_no INTEGER DEFAULT 1"
)
conn.execute("UPDATE store_entities SET version_no = 1 WHERE version_no IS NULL")
nullable = conn.execute(
"SELECT is_nullable FROM information_schema.columns "
"WHERE table_name = 'store_entities' AND column_name = 'version_no'"
).fetchone()
if nullable and nullable[0] == "YES":
conn.execute("ALTER TABLE store_entities ALTER COLUMN version_no SET NOT NULL")
conn.execute(
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_history JSON DEFAULT '[]'"
)
# Backfill: synthesize a v1 entry from existing columns when the
# history is empty. Idempotent — re-running on a populated row
# is a no-op because the WHERE filters on empty/NULL history.
# history is empty. Idempotent — re-running on a populated row is
# a no-op because the WHERE filters on empty/NULL history.
conn.execute(
"""
UPDATE store_entities SET version_history = json_array(
json_object(
@ -2674,8 +2739,8 @@ _V37_TO_V38_MIGRATIONS = [
WHERE version_history IS NULL
OR version_history = '[]'
OR json_array_length(version_history) = 0
""",
]
"""
)
# v39: marketplace_plugins.is_system flag backing the "system plugin"
@ -3074,6 +3139,116 @@ def _v47_to_v48(conn: duckdb.DuckDBPyConnection) -> None:
conn.execute("CREATE INDEX IF NOT EXISTS idx_miw_lookup ON usage_marketplace_item_window(period_label, source, type)")
def _v48_to_v49_migrate(conn: duckdb.DuckDBPyConnection) -> None:
"""v49: phase-1 Flea refactor — add ``title``, ``tagline``, ``synthetic_name``.
Python function (not a SQL list) because the backfill needs Python-side
humanize logic (acronym dict + Title Case) which has no clean SQL
equivalent. Pattern mirrors ``_v34_to_v35_migrate``.
Steps:
1. Add columns nullable + default NULL so the ALTER works on a populated
table.
2. Iterate rows: compute ``title = humanize_name(strip_archive_suffix(name))``
and ``synthetic_name = f"{name}-by-{owner_username}"``. ``tagline``
stays NULL.
3. SET NOT NULL on ``title`` and ``synthetic_name``. ``tagline`` stays
nullable by design (optional short description).
Idempotent: re-runs are safe ADD COLUMN IF NOT EXISTS is a no-op;
UPDATEs overwrite with the same values; ALTER ... SET NOT NULL is a
no-op when already NOT NULL.
"""
from src.store_naming import humanize_name, strip_archive_suffix
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS title VARCHAR")
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS tagline VARCHAR")
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS synthetic_name VARCHAR")
rows = conn.execute(
"SELECT id, name, owner_username FROM store_entities"
).fetchall()
for row_id, name, owner_username in rows:
display_base = strip_archive_suffix(name or "")
title = humanize_name(display_base) or display_base or "Untitled"
synthetic = f"{name}-by-{owner_username}"
conn.execute(
"UPDATE store_entities SET title = ?, synthetic_name = ? WHERE id = ?",
[title, synthetic, row_id],
)
# Gate ALTER … SET NOT NULL on current nullability. DuckDB blocks
# ALTER COLUMN once an index references the table (which happens after
# v50 creates the UNIQUE INDEX on synthetic_name), so an unconditional
# re-run on a fully-migrated DB would explode. Idempotent path: skip
# the ALTER when the column is already NOT NULL.
nullable = {
r[0]: r[1]
for r in conn.execute(
"SELECT column_name, is_nullable FROM information_schema.columns "
"WHERE table_name = 'store_entities' "
"AND column_name IN ('title', 'synthetic_name')"
).fetchall()
}
if nullable.get("title") == "YES":
conn.execute("ALTER TABLE store_entities ALTER COLUMN title SET NOT NULL")
if nullable.get("synthetic_name") == "YES":
conn.execute("ALTER TABLE store_entities ALTER COLUMN synthetic_name SET NOT NULL")
def _v49_to_v50_migrate(conn: duckdb.DuckDBPyConnection) -> None:
"""v50: enforce DB-level uniqueness on ``store_entities.synthetic_name``.
v49 introduced the column as NOT NULL but without uniqueness. App-level
``_suffixed_already_taken`` only fires at upload/rename; any other write
path (admin DB hand-fix, future migration drift) could silently insert a
duplicate, and ``WHERE synthetic_name = ?`` would then non-deterministically
return one of the matching rows. With ``synthetic_name`` now the canonical
attribution key (rollup tables, marketplace bundle naming, JSONL invocation
prefix), uniqueness must be enforced at the DB level.
DuckDB has no ``ALTER TABLE ADD CONSTRAINT UNIQUE`` for existing tables,
but ``CREATE UNIQUE INDEX`` is functionally equivalent (rejects duplicate
inserts). The archive rewrite path
(``StoreEntitiesRepository.archive``) renames synthetic_name alongside
name, so archived rows cannot collide with live ones a full-table
UNIQUE index is correct.
Steps:
1. Pre-flight: scan for existing duplicates. If any are found, abort
with ``RuntimeError`` listing them the index creation would fail
anyway, but a structured error gives the operator a clear diagnostic
instead of a raw DuckDB constraint-violation message.
2. Create the UNIQUE index (idempotent via IF NOT EXISTS).
Idempotent: a re-run finds the index already present and skips both
the duplicate scan (which would still pass) and the CREATE.
"""
# Pre-flight duplicate detection. List the actual conflicting slugs +
# row counts so the operator can resolve manually (typically by
# archiving one of the colliding rows, which rewrites its
# synthetic_name to the __archived__<epoch>-suffixed form).
dupes = conn.execute(
"""SELECT synthetic_name, COUNT(*) AS n
FROM store_entities
GROUP BY synthetic_name
HAVING COUNT(*) > 1
ORDER BY n DESC, synthetic_name"""
).fetchall()
if dupes:
summary = ", ".join(f"{name!r} x{n}" for name, n in dupes)
raise RuntimeError(
"v49→v50 migration blocked: duplicate synthetic_name values "
f"present in store_entities ({summary}). Resolve manually "
"(archive or rename the colliding rows) and re-run."
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_store_entities_synthetic_name "
"ON store_entities(synthetic_name)"
)
_V33_TO_V34_MIGRATIONS = [
# DuckDB blocks DROP COLUMN while indexes reference the table
# ("Dependency Error: Cannot alter entry … because there are entries
@ -3368,6 +3543,16 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
# there because the legacy tables aren't in _SYSTEM_SCHEMA
# anymore. Kept here for ladder readability.
_v47_to_v48(conn)
# v49 phase-1 Flea refactor — title, tagline, synthetic_name
# columns. _SYSTEM_SCHEMA already declares them on fresh
# installs; this call is a no-op (table empty, ALTER IF NOT
# EXISTS, no rows to backfill, SET NOT NULL idempotent).
_v48_to_v49_migrate(conn)
# v50 UNIQUE INDEX on synthetic_name. _SYSTEM_SCHEMA already
# creates the index on fresh installs; this call is a no-op
# (table empty so no duplicates possible, CREATE UNIQUE
# INDEX IF NOT EXISTS is idempotent).
_v49_to_v50_migrate(conn)
# Fresh-install seed is handled by the unconditional
# _seed_core_roles call at the bottom of _ensure_schema —
# left as a no-op branch here so the migration ladder still
@ -3488,14 +3673,12 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
if current < 35:
_v34_to_v35_migrate(conn)
if current < 36:
for sql in _V35_TO_V36_MIGRATIONS:
conn.execute(sql)
_v35_to_v36_migrate(conn)
if current < 37:
for sql in _V36_TO_V37_MIGRATIONS:
conn.execute(sql)
if current < 38:
for sql in _V37_TO_V38_MIGRATIONS:
conn.execute(sql)
_v37_to_v38_migrate(conn)
if current < 39:
for sql in _V38_TO_V39_MIGRATIONS:
conn.execute(sql)
@ -3518,6 +3701,10 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
_v46_to_v47(conn)
if current < 48:
_v47_to_v48(conn)
if current < 49:
_v48_to_v49_migrate(conn)
if current < 50:
_v49_to_v50_migrate(conn)
conn.execute(
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
[SCHEMA_VERSION],

View file

@ -48,7 +48,6 @@ from src.repositories.user_curated_subscriptions import (
UserCuratedSubscriptionsRepository,
)
from src.repositories.user_store_installs import UserStoreInstallsRepository
from src.store_naming import suffixed_name
def _resolve_raw(raw: Any) -> dict:
@ -181,15 +180,21 @@ marketplace. ``is_valid_slug`` in ``src/marketplace.py`` rejects any admin
marketplace registering ``store`` as its slug, so collisions with admin
content are impossible."""
BUNDLE_PLUGIN_NAME = "agnes-store-bundle"
BUNDLE_PLUGIN_NAME = "flea"
"""Synth plugin that wraps every Store-installed skill and agent for a user
into a single Claude Code plugin. Skill / agent uploads share this single
plugin in the served marketplace; only ``type='plugin'`` Store entities
materialize as their own plugin entry. See ``resolve_user_marketplace``."""
materialize as their own plugin entry. See ``resolve_user_marketplace``.
BUNDLE_PREFIXED_NAME = "store-bundle"
v49 phase-4: renamed from ``agnes-store-bundle`` to ``flea``. Clean cut
``usage_events`` rows whose JSONL was written before the rename stay
attributed as ``source='builtin'``; no legacy-prefix fallback in the
attribution layer (``services/session_processors/usage_lib.py``)."""
BUNDLE_PREFIXED_NAME = "flea"
"""On-disk directory name in the served ZIP / git tree for the bundle plugin.
Lives under ``plugins/store-bundle/...``."""
Lives under ``plugins/flea/...``. v49 phase-4: renamed from ``store-bundle``
for parity with the manifest plugin name."""
BUNDLE_DESCRIPTION = "Skills and agents you've installed from the Agnes Store"
@ -307,7 +312,13 @@ def resolve_user_marketplace(
entity_id = row["id"]
owner_username = row["owner_username"]
original_name = row["name"]
manifest_name = suffixed_name(original_name, owner_username)
# v49 phase-3: stored synthetic_name from store_entities is the
# canonical value baked into the on-disk plugin tree
# (frontmatter name, plugin.json `name`). Reading it from DB
# keeps the served manifest in lockstep with whatever the upload
# / edit / archive paths last wrote. The column is NOT NULL +
# explicitly selected by ``list_for_user``.
manifest_name = row["synthetic_name"]
plugin_dir = store_root / entity_id / "plugin"
store_plugin_entries.append(
{

View file

@ -59,7 +59,21 @@ class StoreEntitiesRepository:
doc_paths: Optional[List[str]] = None,
file_size: int = 0,
visibility_status: str = "pending",
title: Optional[str] = None,
tagline: Optional[str] = None,
synthetic_name: Optional[str] = None,
) -> Dict[str, Any]:
# v49 phase-1: title and synthetic_name fall back to derived values
# when caller doesn't supply them — keeps the column NOT NULL invariant
# without forcing every test/utility caller to recompute the same
# deterministic formula. Production code (POST /api/store/entities)
# passes both explicitly so the upload form's user-edited title is
# honored.
if not title:
from src.store_naming import humanize_name
title = humanize_name(name) or name or "Untitled"
if not synthetic_name:
synthetic_name = f"{name}-by-{owner_username}"
now = datetime.now(timezone.utc)
# v37: seed version_history with the v1 entry on create so the
# edit feature's append_version always has a baseline to build
@ -81,14 +95,16 @@ class StoreEntitiesRepository:
category, version, photo_path, video_url, doc_paths,
file_size, install_count, visibility_status,
version_no, version_history,
title, tagline, synthetic_name,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?, ?, ?, ?, ?, ?)""",
[
id, owner_user_id, owner_username, type, name, description,
category, version, photo_path, video_url,
json.dumps(doc_paths or []),
int(file_size), visibility_status,
json.dumps([v1_entry]),
title, tagline, synthetic_name,
now, now,
],
)
@ -283,17 +299,23 @@ class StoreEntitiesRepository:
}
original = row.get("name") or ""
owner_username = row.get("owner_username") or ""
now = datetime.now(timezone.utc)
new_name = make_archive_name(original, int(now.timestamp()))
# v49: synthetic_name tracks the canonical <name>-by-<owner> string;
# rename it alongside `name` so the deterministic formula stays in
# sync with the actually-stored slug on disk.
new_synthetic = f"{new_name}-by-{owner_username}"
self.conn.execute(
"""UPDATE store_entities
SET visibility_status = 'archived',
name = ?,
synthetic_name = ?,
archived_at = ?,
archived_by = ?,
updated_at = ?
WHERE id = ?""",
[new_name, now, by_user_id, now, id],
[new_name, new_synthetic, now, by_user_id, now, id],
)
return {"original_name": original, "new_name": new_name}
@ -439,6 +461,9 @@ class StoreEntitiesRepository:
video_url: Optional[str] = None,
doc_paths: Optional[List[str]] = None,
file_size: Optional[int] = None,
title: Optional[str] = None,
tagline: Optional[str] = None,
synthetic_name: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Partial update — only the supplied columns change. Returns the
updated row, or None if no row matched.
@ -447,6 +472,14 @@ class StoreEntitiesRepository:
responsible for collision checks BEFORE invoking this method
(per-owner UNIQUE + global suffix uniqueness) and for
renaming the on-disk skill/agent/plugin slug to match.
``synthetic_name`` must be re-supplied by the caller when ``name``
changes the repo does not recompute it automatically (avoids a
round-trip to fetch ``owner_username`` and keeps the deterministic
formula in one place: the API layer).
``tagline`` accepts an empty string to clear the field; pass
``None`` to leave it unchanged.
"""
sets: List[str] = []
params: List[Any] = []
@ -466,6 +499,13 @@ class StoreEntitiesRepository:
sets.append("doc_paths = ?"); params.append(json.dumps(doc_paths))
if file_size is not None:
sets.append("file_size = ?"); params.append(int(file_size))
if title is not None:
sets.append("title = ?"); params.append(title)
if tagline is not None:
# empty string clears tagline; sentinel preserved here
sets.append("tagline = ?"); params.append(tagline or None)
if synthetic_name is not None:
sets.append("synthetic_name = ?"); params.append(synthetic_name)
if not sets:
return self.get(id)
sets.append("updated_at = ?"); params.append(datetime.now(timezone.utc))

View file

@ -71,6 +71,7 @@ class UserStoreInstallsRepository:
se.photo_path, se.video_url, se.file_size,
se.install_count, se.created_at, se.updated_at,
se.visibility_status,
se.title, se.tagline, se.synthetic_name,
usi.installed_at
FROM user_store_installs usi
JOIN store_entities se ON se.id = usi.entity_id

View file

@ -125,3 +125,61 @@ def _iter_files(root: Path) -> Iterable[Path]:
if not root.is_dir():
return []
return sorted(p for p in root.rglob("*") if p.is_file())
# Canonical case for tokens that should keep their casing in humanized titles.
# Lookup key is lowercased token; value is the canonical form. Extend the list
# as new acronyms enter the codebase (kept small on purpose — large lists
# silently re-case unrelated user words).
TITLE_ACRONYMS = {
"ai": "AI",
"api": "API",
"aws": "AWS",
"bm25": "BM25",
"bq": "BQ",
"ci": "CI",
"cd": "CD",
"css": "CSS",
"db": "DB",
"fts": "FTS",
"gcp": "GCP",
"html": "HTML",
"js": "JS",
"json": "JSON",
"jwt": "JWT",
"mcp": "MCP",
"oauth": "OAuth",
"rbac": "RBAC",
"rpc": "RPC",
"s3": "S3",
"sql": "SQL",
"sso": "SSO",
"ts": "TS",
"ui": "UI",
"url": "URL",
"ux": "UX",
"xml": "XML",
}
def humanize_name(name: str) -> str:
"""Convert a kebab-case identifier to a user-friendly Title Case string.
Splits on ``-``, drops empty tokens (handles double dashes), and either
looks the token up in ``TITLE_ACRONYMS`` (case-insensitive) or
title-cases it. Joined with spaces.
humanize_name("code-review") -> "Code Review"
humanize_name("mcp-builder") -> "MCP Builder"
humanize_name("oauth-server-v2") -> "OAuth Server V2"
humanize_name("api") -> "API"
humanize_name("") -> ""
"""
if not name:
return ""
tokens = [t for t in name.split("-") if t]
out = []
for tok in tokens:
canonical = TITLE_ACRONYMS.get(tok.lower())
out.append(canonical if canonical is not None else tok.title())
return " ".join(out)

View file

@ -157,7 +157,7 @@ def compute_default_agent_prompt(
# exactly what /marketplace.zip + /marketplace.git/ serve. That's
# the `resolve_user_marketplace` view: admin grants minus the
# user's opt-outs, plus their Store installs (skills + agents
# rolled up into the synth `agnes-store-bundle` plugin, plugin-
# rolled up into the synth `flea` plugin, plugin-
# typed entities standalone). `resolve_allowed_plugins` was the
# pre-store admin-only feed and would emit installs for plugins
# the user has opted out of, while skipping the bundle entirely.

View file

@ -1,4 +1,4 @@
{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "Use the flea skill"}}
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_flea_1", "name": "Skill", "input": {"skill": "agnes-store-bundle:flea-skill", "args": ""}}]}}
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_flea_1", "name": "Skill", "input": {"skill": "flea:flea-skill-by-alice", "args": ""}}]}}
{"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:02.000Z", "cwd": "/workspace", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_flea_1", "is_error": false, "content": [{"type": "text", "text": "Flea skill executed"}]}]}}
{"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:03.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "Done."}]}}

View file

@ -307,13 +307,13 @@ def test_reconcile_updates_when_manifest_version_differs(
monkeypatch.chdir(workspace)
_set_marketplace_manifest(with_clone, [
{"name": "grpn-eng", "version": "1.1.0"}, # admin pushed new version
{"name": "agnes-store-bundle", "version": "deadbeefcafef00d"}, # bundle bumped
{"name": "flea", "version": "deadbeefcafef00d"}, # bundle bumped
])
recorder.script(
("claude", "plugin", "list", "--json"),
stdout=_plugin_list_json([
{"id": "grpn-eng@agnes", "version": "1.0.0", "projectPath": str(workspace)},
{"id": "agnes-store-bundle@agnes", "version": "0123456789abcdef",
{"id": "flea@agnes", "version": "0123456789abcdef",
"projectPath": str(workspace)},
]),
)
@ -325,7 +325,7 @@ def test_reconcile_updates_when_manifest_version_differs(
if c.cmd[:3] == ["claude", "plugin", "update"]
)
assert update_targets == [
f"agnes-store-bundle@{rm_module.MARKETPLACE_NAME}",
f"flea@{rm_module.MARKETPLACE_NAME}",
f"grpn-eng@{rm_module.MARKETPLACE_NAME}",
]
# No installs (both already present).

View file

@ -14,7 +14,7 @@ import duckdb
from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
def test_schema_version_is_48():
def test_schema_version_matches_constant():
# v27 → v28: explicit-install (Model B) for curated marketplace plugins.
# user_plugin_optouts row presence flips meaning from "excluded" to
# "subscribed"; migration wipes existing rows so the inverted reading
@ -125,7 +125,22 @@ def test_schema_version_is_48():
# New attribution logic = prefix split on `<plugin>:<local>`
# identifier + live lookup against marketplace_plugins /
# store_entities — no mapping tables needed.
assert SCHEMA_VERSION == 48
# v49 (#TBD): phase-1 Flea refactor — adds title, tagline,
# synthetic_name columns to store_entities. title is
# user-friendly display name (acronym-aware), tagline is
# an optional 200-char short description, synthetic_name is
# the deterministic <name>-by-<owner_username> string baked
# into served bundles. Migration backfills existing rows
# via humanize_name(strip_archive_suffix(name)) for title
# and the concat formula for synthetic_name.
# v50 (#TBD): UNIQUE INDEX on store_entities.synthetic_name. v49 made
# it the canonical attribution key (rollup keyspace, JSONL
# prefix, marketplace bundle naming) but uniqueness was
# only app-enforced; v50 adds DB-level uniqueness via
# CREATE UNIQUE INDEX. Migration pre-checks for existing
# duplicates and raises RuntimeError listing them rather
# than letting the index create fail mid-way.
assert SCHEMA_VERSION == 50
def test_v37_marketplace_curator_columns(tmp_path):

View file

@ -20,7 +20,6 @@ import duckdb
import pytest
from src.db import (
SCHEMA_VERSION,
_SYSTEM_SCHEMA,
_ensure_schema,
_v43_to_v44,
@ -87,11 +86,6 @@ def test_v43_to_v44_upgrade_is_idempotent(tmp_path):
}
def test_schema_version_constant_is_48():
"""Belt + suspenders against schema_version regressions."""
assert SCHEMA_VERSION == 48
# ---------------------------------------------------------------------------
# compute_home_stats / GET /api/me/home-stats
# ---------------------------------------------------------------------------

View file

@ -114,6 +114,28 @@ def _make_skill_zip(skill_name: str = "code-review") -> bytes:
return buf.getvalue()
def _make_plugin_zip(plugin_name: str, inner_skill: str = "dummy") -> bytes:
"""Mirror of test_store_api._make_plugin_zip — minimal flea plugin
ZIP with one inner skill, used to drive ``/api/marketplace/flea/{id}
/skill/{name}`` inner-detail tests."""
import json
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(
".claude-plugin/plugin.json",
json.dumps({
"name": plugin_name,
"description": _OK_DESC,
"version": "0.1",
}),
)
zf.writestr(
f"skills/{inner_skill}/SKILL.md",
f"---\nname: {inner_skill}\ndescription: {_OK_DESC}\n---\n\n{_OK_BODY}",
)
return buf.getvalue()
# ---------------------------------------------------------------------------
# /api/marketplace/items
# ---------------------------------------------------------------------------
@ -151,9 +173,14 @@ class TestListItems:
assert r.status_code == 200
data = r.json()
assert data["total"] == 1
assert data["items"][0]["source"] == "flea"
# Invocation name suffixed with -by-<owner>
assert "alpha" in data["items"][0]["name"]
item = data["items"][0]
assert item["source"] == "flea"
# v49 phase-1: `name` is the suffixed invocation slug — kept as the
# technical identifier card JS falls back to when display_name is
# absent. v49 phase-2: `display_name` carries the humanized title
# (`Alpha`), and JS uses it as the visible card heading.
assert item["name"] == "alpha-by-alice"
assert item["display_name"] == "Alpha"
def test_my_subscriptions_default_empty(self, web_client):
"""Without explicit install, a granted curated plugin doesn't show
@ -674,3 +701,311 @@ class TestFleaDetail:
assert d["install_count"] == 0
# docs is always a list (empty when uploader didn't ship any).
assert isinstance(d["docs"], list)
# ---------------------------------------------------------------------------
# v49 phase-2 — title + tagline + full-name owner on flea presentation
# ---------------------------------------------------------------------------
def _set_user_full_name(user_id: str, full_name: str) -> None:
"""Override the `users.name` field for an existing test user. Used to
simulate the real-world case where a user has a proper full name
(e.g. "Carolina Bsolinová Pauerová") distinct from their kebab-case
`owner_username` derived from email (`c-bsolinovapauerova`)."""
from src.db import get_system_db
conn = get_system_db()
try:
conn.execute("UPDATE users SET name = ? WHERE id = ?", [full_name, user_id])
finally:
conn.close()
class TestFleaPhase2Presentation:
"""v49 phase-2 — flea cards and detail pages surface `title` (humanized),
`tagline`, and the owner's full name (`users.name`) instead of the
kebab-case slug + bare username they used to render."""
def test_flea_card_carries_title_tagline_and_full_name_owner(self, web_client):
user_id, cookies = _create_user(web_client, "c_marustamyan@x.com")
# Simulate a real account whose users.name is the friendly form;
# owner_username on the entity will be the sanitized kebab-case
# local-part ("c-marustamyan").
_set_user_full_name(user_id, "Minas Arustamyan")
web_client.post(
"/api/store/entities",
files={
"file": ("s.zip", _make_skill_zip("mcp-builder"), "application/zip"),
},
data={
"type": "skill",
"description": _OK_DESC,
"tagline": "Spawns MCP servers from a one-line prompt.",
},
cookies=cookies,
)
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
it = items[0]
# display_name carries the acronym-aware humanized title from
# store_entities.title; JS card uses it as the visible heading.
assert it["display_name"] == "MCP Builder"
# tagline rides the existing curated chain; JS prefers it over
# description for the card subtitle.
assert it["tagline"] == "Spawns MCP servers from a one-line prompt."
# owner is now the full users.name, not the kebab-case slug.
assert it["owner"] == "Minas Arustamyan"
# The technical suffixed slug stays on `name` as the JS-fallback
# identifier (legacy compat — no card UI surfaces it directly).
assert it["name"] == "mcp-builder-by-c-marustamyan"
def test_flea_card_owner_falls_back_to_email_then_username(self, web_client):
"""When users.name is NULL, owner display falls back to users.email;
when neither is present, to owner_username (defensive bottom)."""
from src.db import get_system_db
user_id, cookies = _create_user(web_client, "bob@x.com")
# Clear the seeded users.name so the fallback chain kicks in.
conn = get_system_db()
try:
conn.execute("UPDATE users SET name = NULL WHERE id = ?", [user_id])
finally:
conn.close()
web_client.post(
"/api/store/entities",
files={"file": ("s.zip", _make_skill_zip("alpha"), "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
assert r.status_code == 200
it = r.json()["items"][0]
# Fallback: users.name=NULL → users.email → "bob@x.com".
assert it["owner"] == "bob@x.com"
def test_flea_detail_exposes_title_and_tagline(self, web_client):
_, cookies = _create_user(web_client, "alice@x.com")
up = web_client.post(
"/api/store/entities",
files={
"file": ("s.zip", _make_skill_zip("oauth-server"), "application/zip"),
},
data={
"type": "skill",
"description": _OK_DESC,
"tagline": "Mock OAuth provider for integration tests.",
},
cookies=cookies,
)
entity_id = up.json()["id"]
r = web_client.get(
f"/api/marketplace/flea/{entity_id}/detail", cookies=cookies,
)
assert r.status_code == 200, r.text
d = r.json()
# `display_name` is the curated-style hero title — phase 2 wires
# it up for flea so the heroTitle JS chain renders the friendly
# form instead of falling through to plugin_name (= entity name).
assert d["display_name"] == "OAuth Server"
assert d["tagline"] == "Mock OAuth provider for integration tests."
# plugin_name + manifest_name unchanged — the JS chain in templates
# uses display_name first; these remain for backward compat with
# paths that have always read the slug.
assert d["plugin_name"] == "oauth-server"
assert d["manifest_name"] == "oauth-server-by-alice"
def test_flea_detail_tagline_null_when_omitted(self, web_client):
"""Tagline is optional — flea entity uploaded without it must
surface as None on detail so the hero element stays hidden."""
_, cookies = _create_user(web_client, "alice@x.com")
up = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", _make_skill_zip("notagline"), "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
entity_id = up.json()["id"]
r = web_client.get(
f"/api/marketplace/flea/{entity_id}/detail", cookies=cookies,
)
assert r.status_code == 200, r.text
d = r.json()
assert d["tagline"] is None
# display_name still set from the humanizer fallback in POST.
assert d["display_name"] == "Notagline"
def test_flea_inner_skill_parent_display_name_uses_title(self, web_client):
"""v49 phase-3: inner skill/agent detail of a flea plugin surfaces
the parent plugin's user-friendly ``title`` (humanized) via
``parent_display_name``. JS chains (breadcrumb 3rd segment, hero
"part of <plugin>", helper "This skill is part of <plugin>",
sidebar "Parent plugin") all read this field first single
source swap drives every surface to the friendly form."""
_, cookies = _create_user(web_client, "alice@x.com")
up = web_client.post(
"/api/store/entities",
files={
"file": (
"p.zip",
_make_plugin_zip("codex-second-opinion", inner_skill="codex-setup"),
"application/zip",
),
},
data={
"type": "plugin",
"description": _OK_DESC,
"title": "Codex Second Opinion",
},
cookies=cookies,
)
assert up.status_code == 201, up.text
entity_id = up.json()["id"]
r = web_client.get(
f"/api/marketplace/flea/{entity_id}/skill/codex-setup",
cookies=cookies,
)
assert r.status_code == 200, r.text
d = r.json()
# Inner skill's own name still comes from frontmatter.
assert d["name"] == "codex-setup"
# Parent identification: manifest_name = entity.name (technical
# slug used by the rename / archive paths); parent_display_name =
# entity.title (the human form rendered everywhere on the UI).
assert d["manifest_name"] == "codex-second-opinion"
assert d["parent_display_name"] == "Codex Second Opinion"
def test_flea_card_and_detail_read_synthetic_name_from_db(self, web_client):
"""v49 phase-3: ``MarketplaceItem.name`` (card) and
``PluginDetailResponse.manifest_name`` (detail) source from the
stored ``synthetic_name`` column. Manually override the column to
a non-canonical value both surfaces must reflect the override,
proving they read the column instead of recomputing
``<name>-by-<owner_username>``."""
from src.db import get_system_db
_, cookies = _create_user(web_client, "syntheticread@x.com")
up = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", _make_skill_zip("orig"), "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
eid = up.json()["id"]
conn = get_system_db()
try:
conn.execute(
"UPDATE store_entities SET synthetic_name = ? WHERE id = ?",
["manual-override-mkt", eid],
)
finally:
conn.close()
# Card
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 1
assert items[0]["name"] == "manual-override-mkt"
# Detail
d = web_client.get(
f"/api/marketplace/flea/{eid}/detail", cookies=cookies,
)
assert d.status_code == 200, d.text
assert d.json()["manifest_name"] == "manual-override-mkt"
def test_flea_inner_skill_parent_display_name_humanize_fallback(self, web_client):
"""When title is omitted on upload, the POST endpoint humanizes the
plugin name as a fallback phase 3 must thread that humanized form
into ``parent_display_name`` too, not the kebab-case slug."""
_, cookies = _create_user(web_client, "alice@x.com")
up = web_client.post(
"/api/store/entities",
files={
"file": (
"p.zip",
_make_plugin_zip("mcp-tools", inner_skill="dummy"),
"application/zip",
),
},
data={"type": "plugin", "description": _OK_DESC},
cookies=cookies,
)
entity_id = up.json()["id"]
r = web_client.get(
f"/api/marketplace/flea/{entity_id}/skill/dummy",
cookies=cookies,
)
assert r.status_code == 200, r.text
d = r.json()
# Humanizer + acronym dict from phase 1 — "mcp-tools" → "MCP Tools".
assert d["parent_display_name"] == "MCP Tools"
assert d["manifest_name"] == "mcp-tools"
# ---------------------------------------------------------------------------
# v49 hardening — owner-display N+1 regression guard
# ---------------------------------------------------------------------------
class TestFleaOwnerDisplayBatched:
"""Pre-fix, ``_flea_to_item`` called ``_resolve_owner_display`` for every
item in the list comprehension one ``SELECT FROM users WHERE id = ?``
per item. v49 hardening batches the prefetch via ``_load_users_display``
so the listing endpoint runs O(1) user lookups regardless of item count.
Regression guard: assert ``_resolve_owner_display`` is NEVER invoked
during the flea listing path. If a future change reintroduces the per-row
helper inside ``_flea_to_item``, this fails immediately."""
def test_listing_does_not_call_per_row_owner_resolver(self, web_client, monkeypatch):
# Three owners + three flea uploads — enough to make any per-row
# call obviously visible if it sneaks back in.
for email in ("alice@x.com", "bob@x.com", "carol@x.com"):
_, cookies = _create_user(web_client, email)
web_client.post(
"/api/store/entities",
files={
"file": (
"s.zip",
_make_skill_zip(f"skill-{email.split('@')[0]}"),
"application/zip",
),
},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
# Any logged-in user can see the public flea tab.
_, viewer_cookies = _create_user(web_client, "viewer@x.com")
# Spy on the per-row resolver. The fixed implementation must not
# call it inside the listing comprehension.
from app.api import marketplace as marketplace_module
calls: list[str] = []
original = marketplace_module._resolve_owner_display
def spy(conn, owner_user_id, fallback):
calls.append(owner_user_id)
return original(conn, owner_user_id, fallback)
monkeypatch.setattr(marketplace_module, "_resolve_owner_display", spy)
r = web_client.get(
"/api/marketplace/items?tab=flea", cookies=viewer_cookies,
)
assert r.status_code == 200, r.text
items = r.json()["items"]
assert len(items) == 3
assert calls == [], (
f"_resolve_owner_display called {len(calls)} times inside the "
f"flea listing path — regression to the N+1 pattern."
)
# All three owner display strings still resolved correctly via the
# batch prefetch (so we're not just sidestepping the assertion by
# returning blank owners).
owners = sorted(it["owner"] for it in items)
assert owners == ["alice", "bob", "carol"]

View file

@ -130,7 +130,7 @@ class TestResolveUserMarketplace:
def test_skill_install_yields_bundle_entry(self, db_conn):
"""A single skill install becomes a bundle entry, not a standalone
store-<id> plugin. Skills/agents are merged into one synth plugin
named ``agnes-store-bundle`` regardless of how many are installed."""
named ``flea`` regardless of how many are installed."""
from src.marketplace_filter import resolve_user_marketplace
_seed_user_with_grant(db_conn, marketplace="mkt", plugin="p1")
_subscribe(db_conn, user_id="u1", marketplace="mkt", plugin="p1")
@ -143,8 +143,8 @@ class TestResolveUserMarketplace:
admin_e = next(p for p in result if p["source"] == "marketplace")
bundle = next(p for p in result if p["source"] == "store-bundle")
assert admin_e["prefixed_name"] == "mkt-p1"
assert bundle["prefixed_name"] == "store-bundle"
assert bundle["manifest_name"] == "agnes-store-bundle"
assert bundle["prefixed_name"] == "flea"
assert bundle["manifest_name"] == "flea"
assert bundle["marketplace_id"] == "store"
assert bundle["plugin_dir"] is None
assert eid in bundle["bundle_entity_ids"]

View file

@ -1,15 +1,7 @@
"""v41 → v42 migration: 7 new usage_* tables for telemetry."""
import duckdb
import pytest
from src.db import _ensure_schema as init_database, SCHEMA_VERSION
def test_schema_version_is_42():
# Test name preserved for git-blame continuity; the version-pinned
# tests in test_db_schema_version.py, test_home_stats.py and
# test_schema_v46_migration.py carry the current commentary.
assert SCHEMA_VERSION == 48
from src.db import _ensure_schema as init_database
def test_v42_tables_exist_after_init(tmp_path):
@ -74,7 +66,7 @@ def test_v41_to_v42_is_idempotent(tmp_path):
conn = duckdb.connect(str(db_path))
init_database(conn)
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v == 48
assert v == 50
conn.close()
@ -95,7 +87,7 @@ def test_v41_db_upgrades_cleanly(tmp_path):
conn = duckdb.connect(str(db_path))
init_database(conn)
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v == 48
assert v == 50
# All 7 new v41 tables exist after the v40→v41 upgrade
tables = {
row[0]
@ -126,7 +118,7 @@ def test_v30_db_ladders_all_the_way_up(tmp_path):
conn = duckdb.connect(str(db_path))
init_database(conn)
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert v == 48
assert v == 50
cnt = conn.execute("SELECT COUNT(*) FROM audit_log WHERE id='vintage'").fetchone()[0]
assert cnt == 1
# New v41 table exists

View file

@ -13,10 +13,6 @@ import duckdb
from src.db import SCHEMA_VERSION, _ensure_schema, _v45_to_v46, get_schema_version
def test_schema_version_is_46():
assert SCHEMA_VERSION == 48
def test_fresh_install_creates_dismissed_table(tmp_path):
"""A brand-new DB ends at v46 with the dismiss table + index in place."""
db_path = tmp_path / "system.duckdb"

View file

@ -0,0 +1,162 @@
"""v48 → v49 migration: phase-1 Flea refactor.
Adds three columns to ``store_entities``:
- ``title`` (VARCHAR NOT NULL) humanized display name, backfilled via
``humanize_name(strip_archive_suffix(name))`` on existing rows.
- ``tagline`` (VARCHAR, nullable) optional 200-char short description.
- ``synthetic_name`` (VARCHAR NOT NULL) deterministic
``<name>-by-<owner_username>`` string, backfilled via the concat formula.
The migration is implemented as a Python function (``_v48_to_v49_migrate``)
because the humanize backfill has no clean SQL equivalent.
"""
import duckdb
import pytest
from src.db import (
SCHEMA_VERSION,
_ensure_schema,
_v48_to_v49_migrate,
get_schema_version,
)
def test_fresh_install_has_v49_columns(tmp_path):
"""Fresh install reaches v49 with the three new columns present."""
db_path = tmp_path / "system.duckdb"
conn = duckdb.connect(str(db_path))
_ensure_schema(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
cols = {
r[0]
for r in conn.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'store_entities'"
).fetchall()
}
assert {"title", "tagline", "synthetic_name"} <= cols, (
f"v49 columns missing on store_entities: {cols}"
)
conn.close()
def test_v48_db_migrates_and_backfills(tmp_path):
"""A pre-existing v48 DB with seeded store_entities rows climbs to v49
with title + synthetic_name backfilled and tagline left NULL."""
db_path = tmp_path / "v48.duckdb"
conn = duckdb.connect(str(db_path))
# Stand up a minimal v48-shape store_entities (no title/tagline/synthetic).
conn.execute(
"CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)"
)
conn.execute("INSERT INTO schema_version (version) VALUES (48)")
conn.execute(
"""CREATE TABLE store_entities (
id VARCHAR PRIMARY KEY,
owner_user_id VARCHAR NOT NULL,
owner_username VARCHAR NOT NULL,
type VARCHAR NOT NULL,
name VARCHAR NOT NULL,
description TEXT,
category VARCHAR,
version VARCHAR NOT NULL,
photo_path VARCHAR,
video_url VARCHAR,
doc_paths JSON,
file_size BIGINT,
install_count BIGINT NOT NULL DEFAULT 0,
visibility_status VARCHAR NOT NULL DEFAULT 'pending',
archived_at TIMESTAMP,
archived_by VARCHAR,
version_no INTEGER NOT NULL DEFAULT 1,
version_history JSON DEFAULT '[]',
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp
)"""
)
# Plain skill, archived skill, MCP acronym, multi-word with v-suffix.
conn.execute(
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version) "
"VALUES ('e1', 'u1', 'alice', 'skill', 'code-review', 'v1')"
)
conn.execute(
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version) "
"VALUES ('e2', 'u2', 'bob', 'agent', 'mcp-builder', 'v1')"
)
conn.execute(
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version, visibility_status) "
"VALUES ('e3', 'u1', 'alice', 'skill', 'oldname__archived__1700000000', 'v1', 'archived')"
)
conn.execute(
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version) "
"VALUES ('e4', 'u3', 'c-bsolinovapauerova', 'skill', 'html-deck-creator', 'v1')"
)
_ensure_schema(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
rows = {
r[0]: r
for r in conn.execute(
"SELECT id, name, title, tagline, synthetic_name FROM store_entities"
).fetchall()
}
# title: humanize_name(strip_archive_suffix(name))
assert rows["e1"][2] == "Code Review"
assert rows["e2"][2] == "MCP Builder"
# Archived row: strip __archived__<epoch> before humanizing.
assert rows["e3"][2] == "Oldname"
assert rows["e4"][2] == "HTML Deck Creator"
# tagline stays NULL — no backfill source.
for eid in ("e1", "e2", "e3", "e4"):
assert rows[eid][3] is None, f"{eid} tagline should be NULL"
# synthetic_name uses the actually-stored name (incl. archive suffix).
assert rows["e1"][4] == "code-review-by-alice"
assert rows["e2"][4] == "mcp-builder-by-bob"
assert rows["e3"][4] == "oldname__archived__1700000000-by-alice"
assert rows["e4"][4] == "html-deck-creator-by-c-bsolinovapauerova"
conn.close()
def test_v48_to_v49_function_is_idempotent(tmp_path):
"""Calling ``_v48_to_v49_migrate`` twice is a no-op the second time."""
db_path = tmp_path / "twice.duckdb"
conn = duckdb.connect(str(db_path))
_ensure_schema(conn)
# Re-run directly on a clean v49 DB — ADD COLUMN IF NOT EXISTS +
# SET NOT NULL on an already-NOT-NULL column are both idempotent.
_v48_to_v49_migrate(conn)
_v48_to_v49_migrate(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
conn.close()
def test_not_null_constraint_after_migration(tmp_path):
"""title and synthetic_name must be NOT NULL after migration."""
db_path = tmp_path / "constraints.duckdb"
conn = duckdb.connect(str(db_path))
_ensure_schema(conn)
# information_schema reports NOT NULL via is_nullable = 'NO'
nullable = {
r[0]: r[1]
for r in conn.execute(
"SELECT column_name, is_nullable FROM information_schema.columns "
"WHERE table_name = 'store_entities' "
"AND column_name IN ('title', 'tagline', 'synthetic_name')"
).fetchall()
}
assert nullable.get("title") == "NO", f"title nullable: {nullable}"
assert nullable.get("synthetic_name") == "NO", f"synthetic_name nullable: {nullable}"
assert nullable.get("tagline") == "YES", f"tagline must be nullable: {nullable}"
conn.close()

View file

@ -0,0 +1,201 @@
"""v49 → v50 migration: UNIQUE INDEX on ``store_entities.synthetic_name``.
v49 introduced ``synthetic_name`` as ``NOT NULL`` but without uniqueness.
With the column now the canonical attribution key (rollup keyspace, JSONL
prefix, marketplace bundle naming), v50 enforces uniqueness at the DB
level via ``CREATE UNIQUE INDEX`` (DuckDB has no
``ALTER TABLE ADD CONSTRAINT UNIQUE`` for existing tables).
Migration must:
- Pre-flight scan for existing duplicates and raise ``RuntimeError`` with
a structured diagnostic listing them (instead of letting the index
create fail mid-way with a raw DuckDB error).
- Create the UNIQUE index idempotently (re-runs are a no-op).
- Cover both fresh installs (index present in ``_SYSTEM_SCHEMA``) and
upgrades from v49 (migration creates the index).
"""
import duckdb
import pytest
from src.db import (
SCHEMA_VERSION,
_ensure_schema,
_v49_to_v50_migrate,
get_schema_version,
)
def test_fresh_install_has_unique_index_on_synthetic_name(tmp_path):
"""Fresh install reaches v50 with the UNIQUE index on synthetic_name."""
db_path = tmp_path / "system.duckdb"
conn = duckdb.connect(str(db_path))
_ensure_schema(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
# DuckDB exposes indexes via duckdb_indexes(); is_unique flag distinguishes
# UNIQUE indexes from non-unique ones.
rows = conn.execute(
"SELECT index_name, is_unique FROM duckdb_indexes() "
"WHERE table_name = 'store_entities'"
).fetchall()
index_names = {r[0]: r[1] for r in rows}
assert "idx_store_entities_synthetic_name" in index_names, (
f"synthetic_name UNIQUE index missing on fresh install: {index_names}"
)
assert index_names["idx_store_entities_synthetic_name"] is True, (
"idx_store_entities_synthetic_name exists but is not UNIQUE"
)
conn.close()
def test_fresh_install_rejects_duplicate_synthetic_name(tmp_path):
"""After fresh install, inserting two rows with the same synthetic_name
raises a DuckDB constraint error the index is actually enforcing."""
db_path = tmp_path / "enforced.duckdb"
conn = duckdb.connect(str(db_path))
_ensure_schema(conn)
conn.execute(
"INSERT INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES ('e1', 'u1', 'alice', 'skill', 'a', '1', 'approved', 'A', 'shared-slug')"
)
with pytest.raises(duckdb.ConstraintException):
conn.execute(
"INSERT INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES ('e2', 'u2', 'bob', 'skill', 'b', '1', 'approved', 'B', 'shared-slug')"
)
conn.close()
def test_v49_db_migrates_when_no_duplicates(tmp_path):
"""A v49-shaped DB with clean (non-duplicate) data climbs to v50, and the
UNIQUE index is in place and enforcing."""
db_path = tmp_path / "v49.duckdb"
conn = duckdb.connect(str(db_path))
# Stand up a minimal v49-shape store_entities. No need to populate
# every column — just enough that the duplicate scan and index
# creation see realistic data.
conn.execute(
"CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)"
)
conn.execute("INSERT INTO schema_version (version) VALUES (49)")
conn.execute(
"""CREATE TABLE store_entities (
id VARCHAR PRIMARY KEY,
owner_user_id VARCHAR NOT NULL,
owner_username VARCHAR NOT NULL,
type VARCHAR NOT NULL,
name VARCHAR NOT NULL,
version VARCHAR NOT NULL,
visibility_status VARCHAR NOT NULL DEFAULT 'pending',
title VARCHAR NOT NULL,
tagline VARCHAR,
synthetic_name VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp
)"""
)
conn.execute(
"INSERT INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES ('e1', 'u1', 'alice', 'skill', 'a', '1', 'approved', 'A', 'a-by-alice')"
)
conn.execute(
"INSERT INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES ('e2', 'u2', 'bob', 'skill', 'a', '1', 'approved', 'A', 'a-by-bob')"
)
_ensure_schema(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
# Index in place — duplicate insert is now rejected.
with pytest.raises(duckdb.ConstraintException):
conn.execute(
"INSERT INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES ('e3', 'u3', 'carol', 'skill', 'a', '1', 'approved', 'A', 'a-by-alice')"
)
conn.close()
def test_v49_to_v50_blocks_on_duplicates(tmp_path):
"""If a v49 DB has duplicate synthetic_name rows (admin hand-fix gone
wrong, etc.), the migration raises ``RuntimeError`` listing the
conflicting slugs instead of letting the index create error out
mid-way with a less informative DuckDB message."""
db_path = tmp_path / "v49dupes.duckdb"
conn = duckdb.connect(str(db_path))
conn.execute(
"CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)"
)
conn.execute("INSERT INTO schema_version (version) VALUES (49)")
conn.execute(
"""CREATE TABLE store_entities (
id VARCHAR PRIMARY KEY,
owner_user_id VARCHAR NOT NULL,
owner_username VARCHAR NOT NULL,
type VARCHAR NOT NULL,
name VARCHAR NOT NULL,
version VARCHAR NOT NULL,
visibility_status VARCHAR NOT NULL DEFAULT 'pending',
title VARCHAR NOT NULL,
tagline VARCHAR,
synthetic_name VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT current_timestamp,
updated_at TIMESTAMP DEFAULT current_timestamp
)"""
)
# Two rows colliding on `dup-slug` — wedge condition for v50.
for eid, owner in [("e1", "alice"), ("e2", "bob")]:
conn.execute(
"INSERT INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES (?, ?, ?, 'skill', 'x', '1', 'approved', 'X', 'dup-slug')",
[eid, owner, owner],
)
with pytest.raises(RuntimeError) as excinfo:
_v49_to_v50_migrate(conn)
msg = str(excinfo.value)
assert "dup-slug" in msg, msg
assert "store_entities" in msg, msg
# Migration did not create the index when it bailed — verify the table
# still allows what would be a duplicate (no enforcement yet).
rows = conn.execute(
"SELECT index_name FROM duckdb_indexes() "
"WHERE table_name = 'store_entities' "
"AND index_name = 'idx_store_entities_synthetic_name'"
).fetchall()
assert rows == [], "index should NOT be created when duplicates present"
conn.close()
def test_v49_to_v50_function_is_idempotent(tmp_path):
"""Re-running the migration on an already-v50 DB is a no-op.
``CREATE UNIQUE INDEX IF NOT EXISTS`` short-circuits; duplicate
pre-check still passes (no dupes possible with the index already
enforcing)."""
db_path = tmp_path / "twice.duckdb"
conn = duckdb.connect(str(db_path))
_ensure_schema(conn)
_v49_to_v50_migrate(conn)
_v49_to_v50_migrate(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
conn.close()

View file

@ -38,7 +38,7 @@ def _seed_attribution(conn: duckdb.DuckDBPyConnection) -> None:
`myplug:my-agent`, slash commands `myplug:compound` note slash
commands count as skills under the new rules, and `compound:debug`
uses `compound` as the plugin prefix).
- flea bundle prefix `agnes-store-bundle` + entity name `flea-skill`.
- flea bundle prefix `flea` + entity name `flea-skill`.
"""
# Curated plugin — only `name` matters for the lookup; the rest is
# filler to satisfy NOT NULL constraints / referential expectations.
@ -57,11 +57,14 @@ def _seed_attribution(conn: duckdb.DuckDBPyConnection) -> None:
)
# Flea entity — visibility_status='approved' is required (lookup filters
# on it). type='skill' so the resolver places the invocation under
# type='skill' in the rollup.
# type='skill' in the rollup. v49 phase-1 added NOT NULL `title` +
# `synthetic_name`; mirror what the repo's create() fallback would write.
conn.execute(
"INSERT OR IGNORE INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, visibility_status) "
"VALUES ('entity-1', 'u1', 'alice', 'skill', 'flea-skill', '1.0', 'approved')"
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES ('entity-1', 'u1', 'alice', 'skill', 'flea-skill', '1.0', "
" 'approved', 'flea-skill', 'flea-skill-by-alice')"
)
@ -197,10 +200,11 @@ class TestFleaSkill:
_seed_attribution(conn)
_process("skill_flea.jsonl", conn)
# Flea entity bundle prefix → ref_id is '' (no parent plugin),
# normalised to NULL by UsageProcessor.
# normalised to NULL by UsageProcessor. v49 phase-5: JSONL local
# part is the entity's synthetic_name (= `<name>-by-<owner>`).
row = conn.execute(
"SELECT source, ref_id FROM usage_events "
"WHERE skill_name = 'agnes-store-bundle:flea-skill'"
"WHERE skill_name = 'flea:flea-skill-by-alice'"
).fetchone()
assert row is not None
assert row[0] == "flea"

View file

@ -404,6 +404,163 @@ class TestStoreUpload:
assert r.json()["detail"] in {"zip_looks_like_plugin", "zip_looks_like_skill"}
class TestStoreV49Metadata:
"""v49 phase-1 — title, tagline, synthetic_name fields end-to-end.
Preview returns humanized title; POST accepts user-supplied title/tagline
and falls back to the humanizer; PUT round-trips the partial update; the
response always carries the v49 columns.
"""
def test_preview_returns_humanized_title(self, web_client):
_, cookies = _create_user(web_client, "preview@x.com")
zip_bytes = _make_skill_zip("mcp-builder")
r = web_client.post(
"/api/store/entities/preview",
files={"file": ("s.zip", zip_bytes, "application/zip")},
data={"type": "skill"},
cookies=cookies,
)
assert r.status_code == 200, r.text
body = r.json()
assert body["name"] == "mcp-builder"
assert body["title"] == "MCP Builder", body
def test_post_with_explicit_title_and_tagline(self, web_client):
_, cookies = _create_user(web_client, "v49post@x.com")
zip_bytes = _make_skill_zip("code-review")
r = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", zip_bytes, "application/zip")},
data={
"type": "skill",
"description": _OK_DESC,
"title": "PR Reviewer (custom)",
"tagline": "Spots missing tests and weak assertions.",
},
cookies=cookies,
)
assert r.status_code == 201, r.text
body = r.json()
assert body["title"] == "PR Reviewer (custom)"
assert body["tagline"] == "Spots missing tests and weak assertions."
assert body["synthetic_name"] == "code-review-by-v49post"
def test_post_falls_back_to_humanized_title_when_omitted(self, web_client):
_, cookies = _create_user(web_client, "fallback@x.com")
zip_bytes = _make_skill_zip("oauth-server")
r = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", zip_bytes, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
assert r.status_code == 201, r.text
body = r.json()
# Server-side humanize fallback uses the same acronym dict as JS.
assert body["title"] == "OAuth Server"
assert body["tagline"] is None
assert body["synthetic_name"] == "oauth-server-by-fallback"
def test_post_rejects_oversize_title(self, web_client):
_, cookies = _create_user(web_client, "oversize@x.com")
zip_bytes = _make_skill_zip("long-title")
r = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", zip_bytes, "application/zip")},
data={"type": "skill", "description": _OK_DESC, "title": "x" * 101},
cookies=cookies,
)
assert r.status_code == 400
assert r.json()["detail"] == "title_too_long"
def test_post_rejects_oversize_tagline(self, web_client):
_, cookies = _create_user(web_client, "oversizetag@x.com")
zip_bytes = _make_skill_zip("long-tag")
r = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", zip_bytes, "application/zip")},
data={
"type": "skill", "description": _OK_DESC,
"tagline": "x" * 201,
},
cookies=cookies,
)
assert r.status_code == 400
assert r.json()["detail"] == "tagline_too_long"
def test_put_updates_title_and_tagline_and_recomputes_synthetic_on_rename(
self, web_client,
):
_, cookies = _create_user(web_client, "v49put@x.com")
zip_bytes = _make_skill_zip("starter-name")
r = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", zip_bytes, "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
assert r.status_code == 201, r.text
eid = r.json()["id"]
# Pure metadata edit: title + tagline.
e = web_client.put(
f"/api/store/entities/{eid}",
data={
"title": "New Title",
"tagline": "A pithy tagline",
},
cookies=cookies,
)
assert e.status_code == 200, e.text
body = e.json()
assert body["title"] == "New Title"
assert body["tagline"] == "A pithy tagline"
# name unchanged → synthetic unchanged.
assert body["synthetic_name"] == "starter-name-by-v49put"
# Rename: synthetic_name must follow.
e2 = web_client.put(
f"/api/store/entities/{eid}",
data={"name": "renamed-thing"},
cookies=cookies,
)
assert e2.status_code == 200, e2.text
assert e2.json()["synthetic_name"] == "renamed-thing-by-v49put"
def test_invocation_name_reads_from_synthetic_column(self, web_client):
"""v49 phase-3: ``invocation_name`` in StoreEntityResponse sources
from the stored ``synthetic_name`` column, not a fresh recompute.
Manually override the column with a non-canonical value and verify
the API returns it verbatim proves read paths consume the column
rather than recomputing ``<name>-by-<owner_username>`` on the fly."""
from src.db import get_system_db
_, cookies = _create_user(web_client, "synthread@x.com")
up = web_client.post(
"/api/store/entities",
files={"file": ("s.zip", _make_skill_zip("orig-name"), "application/zip")},
data={"type": "skill", "description": _OK_DESC},
cookies=cookies,
)
eid = up.json()["id"]
# Manual divergence — pretend an admin fix-up landed a non-canonical
# synthetic. A pure recompute path would not see this; a column-read
# path will.
conn = get_system_db()
try:
conn.execute(
"UPDATE store_entities SET synthetic_name = ? WHERE id = ?",
["manual-override-xyz", eid],
)
finally:
conn.close()
r = web_client.get(f"/api/store/entities/{eid}", cookies=cookies)
assert r.status_code == 200, r.text
body = r.json()
assert body["synthetic_name"] == "manual-override-xyz"
assert body["invocation_name"] == "manual-override-xyz"
class TestStoreSecurityFixes:
"""Regression tests for the three security blockers and one correctness
bug found in PR #180 review (F1, F2, F4, F5)."""
@ -1121,7 +1278,7 @@ class TestInstallCycle:
class TestMarketplaceBundle:
"""End-to-end: the served /marketplace.zip merges installed Store skills
and agents into a single ``store-bundle`` plugin, while ``type='plugin'``
and agents into a single ``flea`` plugin, while ``type='plugin'``
Store entities stay standalone."""
def _zip_entries(self, content: bytes) -> set[str]:
@ -1170,10 +1327,10 @@ class TestMarketplaceBundle:
names = self._zip_entries(r.content)
# Bundle exists with synth plugin.json + every skill + agent file.
assert "plugins/store-bundle/.claude-plugin/plugin.json" in names
assert "plugins/store-bundle/skills/alpha-by-owner/SKILL.md" in names
assert "plugins/store-bundle/skills/beta-by-owner/SKILL.md" in names
assert "plugins/store-bundle/agents/gamma-by-owner.md" in names
assert "plugins/flea/.claude-plugin/plugin.json" in names
assert "plugins/flea/skills/alpha-by-owner/SKILL.md" in names
assert "plugins/flea/skills/beta-by-owner/SKILL.md" in names
assert "plugins/flea/agents/gamma-by-owner.md" in names
# The plugin-typed entity is a separate dir; skills inside its tree
# carry their original (non-suffixed) names per spec.
@ -1184,13 +1341,13 @@ class TestMarketplaceBundle:
r.content, ".claude-plugin/marketplace.json",
))
names_in_catalog = sorted(p["name"] for p in manifest["plugins"])
assert names_in_catalog == ["agnes-store-bundle", "delta-by-owner"]
assert names_in_catalog == ["delta-by-owner", "flea"]
# Bundle's own plugin.json carries the synth fields.
bundle_pj = _json.loads(self._read_zip_file(
r.content, "plugins/store-bundle/.claude-plugin/plugin.json",
r.content, "plugins/flea/.claude-plugin/plugin.json",
))
assert bundle_pj["name"] == "agnes-store-bundle"
assert bundle_pj["name"] == "flea"
assert bundle_pj["version"] # non-empty hash
def test_only_skills_yields_only_bundle(self, web_client):
@ -1206,7 +1363,7 @@ class TestMarketplaceBundle:
r = web_client.get("/marketplace.zip", cookies=installer_cookies)
assert r.status_code == 200
names = self._zip_entries(r.content)
assert "plugins/store-bundle/skills/solo-by-ob/SKILL.md" in names
assert "plugins/flea/skills/solo-by-ob/SKILL.md" in names
# No standalone entry for the skill — bundle is the only Store-derived
# plugin dir present.
assert not any(n.startswith(f"plugins/store-{eid}/") for n in names)
@ -1230,15 +1387,15 @@ class TestMarketplaceBundle:
# Both skills present.
r1 = web_client.get("/marketplace.zip", cookies=installer_cookies)
names1 = self._zip_entries(r1.content)
assert "plugins/store-bundle/skills/keepme-by-oc/SKILL.md" in names1
assert "plugins/store-bundle/skills/dropme-by-oc/SKILL.md" in names1
assert "plugins/flea/skills/keepme-by-oc/SKILL.md" in names1
assert "plugins/flea/skills/dropme-by-oc/SKILL.md" in names1
# Uninstall one — bundle still exists, but only the kept skill remains.
web_client.delete(f"/api/store/entities/{b}/install", cookies=installer_cookies)
r2 = web_client.get("/marketplace.zip", cookies=installer_cookies)
names2 = self._zip_entries(r2.content)
assert "plugins/store-bundle/skills/keepme-by-oc/SKILL.md" in names2
assert "plugins/store-bundle/skills/dropme-by-oc/SKILL.md" not in names2
assert "plugins/flea/skills/keepme-by-oc/SKILL.md" in names2
assert "plugins/flea/skills/dropme-by-oc/SKILL.md" not in names2
class TestWebPages:
@ -1250,7 +1407,14 @@ class TestWebPages:
def test_marketplace_flea_detail_page_renders(self, web_client):
"""v32+: /store/{id} was deleted; /marketplace/flea/{id} is the
canonical detail surface."""
canonical detail surface.
v49 phase-2: SSR pre-render uses ``entity.title`` (humanized)
rather than the kebab-case entity ``name`` for the page heading.
Both the friendly + technical forms should be present in the
page (title in the hero / breadcrumbs, slug in JS data / detail
URL parameter passed to fetch).
"""
_, cookies = _create_user(web_client, "page4@x.com")
r = web_client.post(
"/api/store/entities",
@ -1261,7 +1425,10 @@ class TestWebPages:
eid = r.json()["id"]
det = web_client.get(f"/marketplace/flea/{eid}", cookies=cookies)
assert det.status_code == 200
assert "page-skill" in det.text
# Humanized title sits in the hero h1 + browser title.
assert "Page Skill" in det.text
# Entity id (slug-equivalent for routing) survives in detail URL.
assert eid in det.text
# Confirm the legacy URL is gone (404, not 200).
legacy = web_client.get(f"/store/{eid}", cookies=cookies)
assert legacy.status_code == 404

View file

@ -7,7 +7,9 @@ from pathlib import Path
import pytest
from src.store_naming import (
TITLE_ACRONYMS,
compute_entity_version,
humanize_name,
sanitize_username,
suffixed_name,
)
@ -45,6 +47,39 @@ class TestSuffixedName:
assert suffixed_name("a-b-c", "u-v") == "a-b-c-by-u-v"
class TestHumanizeName:
@pytest.mark.parametrize("name,expected", [
("code-review", "Code Review"),
("mcp-builder", "MCP Builder"),
("oauth-server", "OAuth Server"),
("oauth-server-v2", "OAuth Server V2"),
("s3-uploader", "S3 Uploader"),
("api", "API"),
("single", "Single"),
("json-to-xml", "JSON To XML"),
("html-deck-creator", "HTML Deck Creator"),
("rbac-audit", "RBAC Audit"),
("", ""),
("a", "A"),
# double-dashes / leading-trailing dashes collapse via empty-token drop
("foo--bar", "Foo Bar"),
("-foo-bar-", "Foo Bar"),
])
def test_known_inputs(self, name, expected):
assert humanize_name(name) == expected
def test_acronyms_dict_has_canonical_case(self):
# Sanity — every value is its canonical capitalization, every key is lowercase.
for key, value in TITLE_ACRONYMS.items():
assert key == key.lower(), f"key {key!r} not lowercase"
assert value, f"value for {key!r} is empty"
def test_case_insensitive_match(self):
# Input always arrives lowercase from kebab-case names, but the
# lookup must be defensive in case future callers pass mixed case.
assert humanize_name("MCP-Builder".lower()) == "MCP Builder"
class TestComputeEntityVersion:
def test_deterministic_same_content(self, tmp_path: Path):
a = tmp_path / "a"; a.mkdir()

View file

@ -36,11 +36,16 @@ def _seed_curated_plugin(conn, plugin_name: str, marketplace_id: str = "mp") ->
def _seed_flea_entity(conn, entity_id: str, name: str, type_: str = "skill") -> None:
# v49 phase-1 added NOT NULL `title` + `synthetic_name` columns. Direct
# INSERTs (bypassing repo.create's fallback) must supply both. Mirror
# the formula the repo uses so per-test asserts still see the canonical
# `<name>-by-<owner>` value.
conn.execute(
"INSERT OR IGNORE INTO store_entities "
"(id, owner_user_id, owner_username, type, name, version, visibility_status) "
"VALUES (?, ?, 'alice', ?, ?, '1.0', 'approved')",
[entity_id, "uid-" + entity_id, type_, name],
"(id, owner_user_id, owner_username, type, name, version, "
" visibility_status, title, synthetic_name) "
"VALUES (?, ?, 'alice', ?, ?, '1.0', 'approved', ?, ?)",
[entity_id, "uid-" + entity_id, type_, name, name, f"{name}-by-alice"],
)
@ -147,17 +152,63 @@ class TestMarketplaceItemDaily:
assert row[0] == 3
def test_flea_skill_attributed_with_empty_parent(self, tmp_path, monkeypatch):
"""v49 phase-5: rollup `name` carries the entity's synthetic_name
(`<name>-by-<owner>`), matching what Claude Code writes after the
bundle plugin's `flea:` prefix."""
conn = _fresh_db(tmp_path, monkeypatch)
_seed_flea_entity(conn, "ent-1", "flea-skill", type_="skill")
today = datetime.now(timezone.utc).replace(hour=10, minute=0, second=0, microsecond=0)
_seed_event(conn, occurred_at=today, tool_name="Skill",
skill_name="agnes-store-bundle:flea-skill", event_id="ef-1")
skill_name="flea:flea-skill-by-alice", event_id="ef-1")
rebuild_rollups(conn, since_day=today.date())
row = conn.execute(
"SELECT source, type, parent_plugin, name "
"FROM usage_marketplace_item_daily WHERE source='flea'"
).fetchone()
assert row == ("flea", "skill", "", "flea-skill")
assert row == ("flea", "skill", "", "flea-skill-by-alice")
def test_flea_plugin_row_aggregates_children(self, tmp_path, monkeypatch):
"""v49 phase-6: nested skill/agent invocations under a flea plugin
entity get rolled up into a synthetic plugin-level row mirroring
the curated path. Without this, `_load_invocation_stats('flea')`
(filters `parent_plugin = ''`) returned no row for plugin entity
cards / detail telemetry chips even though nested children were
attributed correctly. `distinct_users` is the union across
children (one user invoking two skills counts once)."""
conn = _fresh_db(tmp_path, monkeypatch)
# Plugin entity with synthetic_name="my-plug-by-alice" — the
# frontmatter-baked name Claude Code writes as the JSONL prefix
# when invoking nested skills inside this flea plugin.
_seed_flea_entity(conn, "ent-plug", "my-plug", type_="plugin")
today = datetime.now(timezone.utc).replace(hour=10, minute=0, second=0, microsecond=0)
# Two nested skills, one of them invoked by two distinct users —
# plugin-level distinct_users should be 2 (union of children),
# not 3 (sum of per-child counts).
_seed_event(conn, occurred_at=today, tool_name="Skill",
skill_name="my-plug-by-alice:setup",
user_id="uid-alice", username="alice", event_id="p1")
_seed_event(conn, occurred_at=today, tool_name="Skill",
skill_name="my-plug-by-alice:setup",
user_id="uid-bob", username="bob", event_id="p2")
_seed_event(conn, occurred_at=today, tool_name="Skill",
skill_name="my-plug-by-alice:review",
user_id="uid-alice", username="alice", event_id="p3")
rebuild_rollups(conn, since_day=today.date())
# Plugin-level aggregated row.
row = conn.execute(
"SELECT source, type, parent_plugin, name, count, distinct_users "
"FROM usage_marketplace_item_daily "
"WHERE source='flea' AND type='plugin'"
).fetchone()
assert row is not None, "flea plugin-level aggregated row missing"
assert row == ("flea", "plugin", "", "my-plug-by-alice", 3, 2)
# Child rows still present alongside the aggregate.
children = conn.execute(
"SELECT name, count FROM usage_marketplace_item_daily "
"WHERE source='flea' AND type='skill' "
"AND parent_plugin='my-plug-by-alice' ORDER BY name"
).fetchall()
assert children == [("review", 1), ("setup", 2)]
def test_unknown_plugin_excluded(self, tmp_path, monkeypatch):
conn = _fresh_db(tmp_path, monkeypatch)