agnes-the-ai-analyst/app/web/templates/store_upload.html
minasarustamyan c6c72b9c00
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>
2026-05-19 02:32:41 +02:00

1292 lines
54 KiB
HTML

{% extends "base.html" %}
{% block title %}Upload to Store — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<style>
/* ── Container override ────────────────────────────────────────── */
/* Width + padding come from .page-shell (style-custom.css) — same
1280px container as /dashboard, /marketplace, /admin/* peers. */
.container:has(.upload-page) > main { margin: 0; padding: 0; }
/* ── Hero (mirrors /setup) ─────────────────────────────────────── */
.upload-hero {
background: linear-gradient(135deg, #0073D1 0%, #0056A3 100%);
border-radius: 12px;
padding: 32px 36px;
margin-bottom: 24px;
box-shadow: 0 4px 16px rgba(0, 115, 209, 0.2);
color: white;
}
.upload-hero .eyebrow {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.8px; color: rgba(255, 255, 255, 0.75);
margin-bottom: 10px;
}
.upload-hero h1 {
font-size: 30px; font-weight: 700; margin: 0 0 8px;
color: #fff; letter-spacing: -0.4px;
}
.upload-hero .sub { font-size: 14px; color: rgba(255,255,255,0.85); line-height: 1.6; }
.upload-hero .meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
.upload-hero .pill {
background: rgba(255,255,255,0.15); padding: 6px 12px;
border-radius: 6px; font-family: var(--font-mono); font-size: 12px; color: #fff;
}
/* ── Progress bar (4-segment style, matches first-time-setup) ──── */
.progress {
display: flex; gap: 8px; margin-bottom: 20px;
}
.progress .seg {
flex: 1; height: 4px; border-radius: 2px;
background: var(--border, #e5e7eb);
transition: background 0.15s ease;
}
.progress .seg.is-done { background: var(--primary, #0073D1); }
/* ── Card (mirrors /setup) ─────────────────────────────────────── */
.card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
overflow: hidden;
margin-bottom: 20px;
}
.card-header { padding: 22px 24px 0; display: flex; align-items: center; gap: 12px; }
.card-body { padding: 16px 24px 24px; }
.step-num {
width: 26px; height: 26px;
background: var(--primary, #0073D1); color: #fff;
border-radius: 50%; display: inline-flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0;
}
.card-title { font-size: 16px; font-weight: 600; color: var(--text-primary, #111827); }
.card-sub {
font-size: 13px; color: var(--text-secondary, #6b7280);
line-height: 1.6; margin: 0 0 14px;
}
.card-sub code {
background: var(--border-light, #f3f4f6); padding: 1px 6px;
border-radius: 4px; font-family: var(--font-mono); font-size: 12px;
color: var(--text-primary, #111827);
}
/* ── Form bits ─────────────────────────────────────────────────── */
.field { margin-bottom: 16px; }
.field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--text-primary, #111827); margin-bottom: 6px;
}
.field-hint { font-size: 12px; color: var(--text-secondary, #6b7280); margin-top: 6px; }
.field input[type=text],
.field input[type=url],
.field textarea,
.field select {
width: 100%; padding: 10px 12px;
border: 1px solid var(--border, #d1d5db); border-radius: 8px;
font-size: 14px; font-family: var(--font-primary, inherit);
box-sizing: border-box; background: #fff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field textarea { min-height: 90px; resize: vertical; }
.field input[type=text]:focus,
.field input[type=url]:focus,
.field textarea:focus,
.field select:focus {
outline: none; border-color: var(--primary, #0073D1);
box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.12);
}
/* ── Type tiles (radio cards) ──────────────────────────────────── */
.type-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.type-tiles label {
cursor: pointer; padding: 14px 12px;
border: 1px solid var(--border, #e5e7eb); border-radius: 10px;
text-align: center; transition: all 0.15s ease;
background: #fff;
}
.type-tiles label:hover { border-color: #c7d2fe; }
.type-tiles label.is-active {
border-color: var(--primary, #0073D1);
background: linear-gradient(180deg, rgba(0,115,209,0.06), rgba(0,115,209,0.02));
box-shadow: 0 0 0 3px rgba(0, 115, 209, 0.12);
}
.type-tiles input[type=radio] { display: none; }
.type-tiles .type-name {
font-weight: 600; font-size: 14px; color: var(--text-primary, #111827); margin-bottom: 4px;
}
.type-tiles .type-hint { font-size: 11px; color: var(--text-secondary, #6b7280); line-height: 1.4; }
/* ── ZIP drop zone (file input wrapper) ────────────────────────── */
.file-drop {
border: 1.5px dashed var(--border, #d1d5db);
border-radius: 10px; padding: 18px;
background: var(--background, #f9fafb);
display: flex; align-items: center; gap: 12px;
transition: all 0.15s ease;
}
.file-drop:hover { border-color: var(--primary, #0073D1); background: #fff; }
.file-drop.is-dragover {
border-color: var(--primary, #0073D1); background: #fff;
box-shadow: 0 0 0 4px rgba(0, 115, 209, 0.15);
transform: scale(1.005);
}
.file-drop.has-file { border-style: solid; border-color: #10b981; background: #f0fdf4; }
.file-drop .icon {
width: 36px; height: 36px; border-radius: 8px;
background: rgba(0, 115, 209, 0.1); color: var(--primary, #0073D1);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; font-size: 16px;
}
.file-drop.has-file .icon { background: rgba(16, 185, 129, 0.1); color: #10b981; }
.file-drop .file-info { flex: 1; min-width: 0; }
.file-drop .file-info .label { font-size: 13px; font-weight: 500; color: var(--text-primary, #111827); }
.file-drop .file-info .label .filename { color: var(--primary, #0073D1); margin-left: 6px; }
.file-drop.has-file .file-info .label { color: #047857; }
.file-drop .file-info .meta { font-size: 12px; color: var(--text-secondary, #6b7280); margin-top: 2px; }
.file-drop input[type=file] { display: none; }
.file-drop button {
appearance: none; padding: 7px 14px;
border: 1px solid var(--border, #d1d5db); background: #fff;
color: var(--text-primary, #111827); border-radius: 8px;
font-size: 12px; font-weight: 500; cursor: pointer;
flex-shrink: 0; font-family: var(--font-primary, inherit);
}
.file-drop button:hover { border-color: var(--primary, #0073D1); color: var(--primary, #0073D1); }
/* ── Doc list ──────────────────────────────────────────────────── */
.doc-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
.doc-item {
display: flex; align-items: center; gap: 10px; justify-content: space-between;
padding: 8px 12px; border: 1px solid var(--border, #e5e7eb);
border-radius: 8px; background: var(--background, #f9fafb); font-size: 13px;
}
.doc-item .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary, #111827); }
.doc-item .size { color: var(--text-secondary, #6b7280); flex-shrink: 0; font-size: 12px; }
.doc-item button {
appearance: none; border: none; background: transparent;
color: #b91c1c; cursor: pointer; font-size: 16px;
padding: 0 4px; line-height: 1;
}
.doc-add {
appearance: none; padding: 8px 14px;
border: 1px dashed var(--border, #d1d5db); background: transparent;
color: var(--text-secondary, #6b7280); border-radius: 8px;
font-size: 13px; cursor: pointer; align-self: flex-start;
font-family: var(--font-primary, inherit);
}
.doc-add:hover { border-color: var(--primary, #0073D1); color: var(--primary, #0073D1); }
/* ── Buttons ───────────────────────────────────────────────────── */
.actions { display: flex; gap: 10px; align-items: center; margin-top: 8px; }
/* ── Status banner ─────────────────────────────────────────────── */
/* `display: flex` on .banner overrides the user-agent default for the
`hidden` attribute, leaving the banner visible even with no error.
Force the override here so the empty banner stays out of the layout. */
.banner[hidden] { display: none !important; }
.banner {
padding: 12px 16px; border-radius: 8px; margin-bottom: 16px;
font-size: 13px; line-height: 1.5; display: flex; align-items: flex-start; gap: 10px;
}
.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.banner.success { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
.banner .ico { flex-shrink: 0; font-size: 16px; line-height: 1.5; }
/* Preserve newlines in structured upload-error messages (multi-line
finding lists). textContent assignment wouldn't render them
otherwise. */
.banner > span { white-space: pre-wrap; }
/* ── Step swap ─────────────────────────────────────────────────── */
.step { display: none; }
.step.is-active { display: block; }
/* ── Description char counter + guidelines disclosure ──────────── */
.desc-counter {
margin-top: 4px; font-size: 12px;
color: var(--text-secondary, #6b7280);
font-family: var(--font-mono);
}
.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);
border: 1px solid var(--border, #e5e7eb);
border-radius: 10px;
overflow: hidden;
/* base.html has a sticky top bar; scroll target needs headroom so
the chevron lands BELOW the bar, not behind it. */
scroll-margin-top: 80px;
}
.guidelines-toggle {
appearance: none; -webkit-appearance: none;
width: 100%; text-align: left;
cursor: pointer;
padding: 12px 14px;
border: 0; background: transparent;
font-size: 13px; font-weight: 600;
color: var(--text-primary, #111827);
font-family: inherit;
user-select: none;
display: flex; align-items: center; gap: 8px;
transition: background 0.12s ease;
/* Same headroom as the wrap so direct .scrollIntoView() on the
button clears the sticky page header. */
scroll-margin-top: 80px;
}
.guidelines-toggle:hover { background: rgba(0, 115, 209, 0.06); }
.guidelines-toggle::before {
content: "▸"; transition: transform 0.15s ease;
display: inline-block; color: var(--primary, #0073D1); font-weight: 700;
}
.guidelines-toggle[aria-expanded="true"] {
background: rgba(0, 115, 209, 0.08);
border-bottom: 1px solid var(--border, #e5e7eb);
}
.guidelines-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }
.guidelines-body {
padding: 10px 14px 14px 30px; font-size: 13px;
color: var(--text-primary, #374151); line-height: 1.6;
background: #ffffff;
}
.guidelines-body[hidden] { display: none; }
.guidelines-body p { margin: 6px 0; }
.guidelines-body ul { margin: 4px 0; padding-left: 18px; }
.guidelines-body code {
background: var(--border-light, #eef2f7); padding: 1px 5px;
border-radius: 4px; font-size: 12px; color: var(--text-primary, #111827);
}
/* ── Component preview table ───────────────────────────────────── */
.comp-list {
margin-top: 12px; border: 1px solid var(--border, #e5e7eb);
border-radius: 8px; overflow: hidden;
font-size: 13px;
}
.comp-list-header {
background: var(--background, #f9fafb);
padding: 8px 12px; font-weight: 600; font-size: 12px;
color: var(--text-secondary, #6b7280); border-bottom: 1px solid var(--border, #e5e7eb);
text-transform: uppercase; letter-spacing: 0.4px;
}
.comp-row {
padding: 8px 12px;
border-top: 1px solid var(--border, #e5e7eb);
display: flex; gap: 10px; align-items: flex-start;
}
.comp-row:first-of-type { border-top: none; }
.comp-dot {
flex-shrink: 0; width: 10px; height: 10px; border-radius: 50%;
margin-top: 6px;
}
.comp-dot.ok { background: #16a34a; }
.comp-dot.bad { background: #dc2626; }
.comp-text { flex: 1; min-width: 0; }
.comp-text .file {
font-family: var(--font-mono); font-size: 12px;
color: var(--text-primary, #111827);
}
.comp-text .type {
display: inline-block; margin-left: 6px; padding: 1px 6px;
border-radius: 4px; background: rgba(0, 115, 209, 0.08);
color: var(--primary, #0073D1); font-size: 11px;
}
.comp-text .preview {
margin-top: 3px; color: var(--text-secondary, #6b7280); font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.comp-text .issue {
margin-top: 3px; color: #b91c1c; font-size: 12px;
}
</style>
<div class="upload-page page-shell">
<div class="upload-hero">
<div class="eyebrow">Store</div>
<h1>Upload an entity</h1>
<p class="sub">
Share a skill, agent, or plugin with everyone on this instance.
Display name will be suffixed with
<code style="background: rgba(255,255,255,0.18); padding: 1px 6px; border-radius: 4px; font-family: var(--font-mono); font-size: 12px;">-by-{{ session.user.email.split('@')[0] }}</code>
so it doesn't collide.
</p>
</div>
<div class="progress">
<div class="seg is-done" id="seg-1"></div>
<div class="seg" id="seg-2"></div>
</div>
<div id="banner" class="banner error" hidden>
<span class="ico">!</span><span id="banner-text"></span>
</div>
<div id="banner-actions" hidden style="margin: -10px 0 14px 0; font-size: 13px;">
<a href="/store/examples" target="_blank" rel="noopener"
style="color: var(--primary, #0073D1); text-decoration: none; font-weight: 500;">
See submission examples ↗
</a>
</div>
<!-- ─── Step 1: Type + ZIP ──────────────────────────────────────── -->
<div id="step-1" class="step is-active">
<div class="card">
<div class="card-header">
<div class="step-num">1</div>
<div class="card-title">Type &amp; ZIP</div>
</div>
<div class="card-body">
<p class="card-sub">Pick what you're uploading and the ZIP archive.
The server validates the layout when you click <code>Next</code>.</p>
<div id="guidelines" class="guidelines">
<button type="button" class="guidelines-toggle" id="guidelines-toggle"
aria-expanded="false" aria-controls="guidelines-body">
Before you upload — what passes review
</button>
<div class="guidelines-body" id="guidelines-body" hidden>
<p style="margin-top: 4px;">
Every component description (plugin, agents, skills, commands)
is the trigger string Claude reads to decide whether to invoke
it. Vague or missing descriptions get rejected even when the
code is fine.
</p>
<p><strong>The bar:</strong></p>
<ul>
<li>Each description ≥ <strong>{{ guardrail.min_description_chars|default(60) }} characters</strong> (commands ≥ {{ guardrail.min_command_description_chars|default(25) }}). Aim for one complete sentence — too short and the assistant can't tell when to use it.</li>
<li>At least {{ guardrail.min_distinct_words|default(5) }} distinct words</li>
<li>No <code>TODO</code> / <code>TBD</code> / template placeholders</li>
<li>Action-oriented, names the trigger condition</li>
</ul>
<p><strong>Patterns that work:</strong></p>
<ul>
<li><strong>Skills</strong>: <code>Use when &lt;trigger&gt;&lt;what it does&gt;</code></li>
<li><strong>Agents</strong>: <code>&lt;What the agent does&gt;. Use for &lt;invocation context&gt;</code></li>
<li><strong>Plugins</strong>: one-sentence marketplace pitch</li>
<li><strong>Commands</strong>: one-verb summary of the action</li>
</ul>
<p style="margin-bottom: 4px;">
The reviewer also runs a substantive pass on descriptions
— generic ones ("a useful skill for working with data")
get flagged even when they clear the mechanical bar.
</p>
<p style="margin-top: 10px;">
<a href="/store/examples" target="_blank" rel="noopener"
style="color: var(--primary, #0073D1); text-decoration: none; font-weight: 500;">
See full examples ↗
</a>
</p>
</div>
</div>
<div class="field">
<label class="field-label">Type</label>
<div class="type-tiles" id="type-tiles">
<label class="is-active">
<input type="radio" name="type" value="skill" checked>
<div class="type-name">Skill</div>
<div class="type-hint">Folder with SKILL.md</div>
</label>
<label>
<input type="radio" name="type" value="agent">
<div class="type-name">Agent</div>
<div class="type-hint">.md file with name + description frontmatter</div>
</label>
<label>
<input type="radio" name="type" value="plugin">
<div class="type-name">Plugin</div>
<div class="type-hint">Directory with .claude-plugin/plugin.json</div>
</label>
</div>
</div>
<div class="field">
<label class="field-label">ZIP archive</label>
{# Drop zone uses a <div> rather than <label> so the implicit
label→input click pickup doesn't fire on top of our explicit
JS handler — that combination opened the file picker twice. #}
<div class="file-drop" id="zip-drop">
<div class="icon">📦</div>
<div class="file-info">
<div class="label" id="zip-label">No file selected</div>
<div class="meta" id="zip-meta">Drag & drop a .zip here, or click Choose file</div>
</div>
<input type="file" id="zip" accept=".zip">
<button type="button" id="zip-pick">Choose file</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="next-btn">Next →</button>
<a href="/marketplace?tab=flea" class="btn btn-secondary">Cancel</a>
</div>
</div>
</div>
</div>
<!-- ─── Step 2: Details ─────────────────────────────────────────── -->
<div id="step-2" class="step">
<div class="card">
<div class="card-header">
<div class="step-num">2</div>
<div class="card-title">Details</div>
</div>
<div class="card-body">
<p class="card-sub">Pre-filled from the ZIP's frontmatter — change anything you want.
Name and description are required; everything below is optional.
</p>
<div id="guidelines-2" class="guidelines">
<button type="button" class="guidelines-toggle" id="guidelines-toggle-2"
aria-expanded="false" aria-controls="guidelines-body-2">
Before you upload — what passes review
</button>
<div class="guidelines-body" id="guidelines-body-2" hidden>
<p style="margin-top: 4px;">
Every component description (plugin, agents, skills, commands)
is the trigger string Claude reads to decide whether to invoke
it. Vague or missing descriptions get rejected even when the
code is fine.
</p>
<p><strong>The bar:</strong></p>
<ul>
<li>Each description ≥ <strong>{{ guardrail.min_description_chars|default(60) }} characters</strong> (commands ≥ {{ guardrail.min_command_description_chars|default(25) }}). Aim for one complete sentence — too short and the assistant can't tell when to use it.</li>
<li>At least {{ guardrail.min_distinct_words|default(5) }} distinct words</li>
<li>No <code>TODO</code> / <code>TBD</code> / template placeholders</li>
<li>Action-oriented, names the trigger condition</li>
</ul>
<p style="margin-top: 10px;">
<a href="/store/examples" target="_blank" rel="noopener"
style="color: var(--primary, #0073D1); text-decoration: none; font-weight: 500;">
See full examples ↗
</a>
</p>
</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="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">
<label class="field-label" for="description">Description</label>
<textarea id="description"
placeholder="Use when reviewing pull requests to flag missing tests, weak assertions, and brittle implementation coupling."></textarea>
<div id="desc-counter" class="desc-counter">0 / 30 minimum</div>
<div class="field-hint">
Shown on the marketplace tile. Same bar as the per-component
descriptions —
<a href="#guidelines-2" id="open-guidelines-link"
style="color: var(--primary, #0073D1); text-decoration: underline;">see Before you upload</a>.
</div>
</div>
<div id="comp-preview" class="field" hidden>
<label class="field-label">Bundle components</label>
<div id="comp-list" class="comp-list"></div>
<div class="field-hint">
Green dots pass the mechanical description bar. The
substantive review runs after you Finish — bundles can still
be flagged for vague descriptions even when every dot is green.
</div>
</div>
<div class="field">
<label class="field-label" for="category">Category</label>
<select id="category">
<option value="">— None —</option>
{% for cat in categories %}
<option value="{{ cat }}">{{ cat }}</option>
{% endfor %}
</select>
<div class="field-hint">Subject area of your entity — helps people filter the Store.</div>
</div>
<div class="field">
<label class="field-label">Cover photo <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<div class="file-drop" id="photo-drop">
<div class="icon">🖼</div>
<div class="file-info">
<div class="label" id="photo-label">No photo</div>
<div class="meta">Drag & drop or click Choose · PNG / JPEG / WebP only · Max 5 MB</div>
</div>
<input type="file" id="photo" accept="image/jpeg,image/png,image/webp">
<button type="button" id="photo-pick">Choose</button>
</div>
</div>
<div class="field">
<label class="field-label" for="video_url">Video URL <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<input type="url" id="video_url" placeholder="https://...">
</div>
<div class="field">
<label class="field-label">Documentation <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
<div class="doc-list" id="doc-list"></div>
<input type="file" id="doc-input" hidden
accept=".pdf,.md,.markdown,.txt,application/pdf,text/markdown,text/plain">
<button type="button" class="doc-add" id="add-doc-btn">+ Add file</button>
<div class="field-hint">PDF, Markdown (.md/.markdown), or plain text (.txt). Max 10 MB per file.</div>
</div>
<div class="actions">
<button type="button" class="btn btn-primary" id="finish-btn">Finish</button>
<button type="button" class="btn btn-secondary" id="back-btn">← Back</button>
</div>
</div>
</div>
</div>
</div>
<script>
const banner = document.getElementById('banner');
const bannerText = document.getElementById('banner-text');
const nextBtn = document.getElementById('next-btn');
const finishBtn = document.getElementById('finish-btn');
const backBtn = document.getElementById('back-btn');
const zipInput = document.getElementById('zip');
const zipPick = document.getElementById('zip-pick');
const zipDrop = document.getElementById('zip-drop');
const zipLabel = document.getElementById('zip-label');
const zipMeta = document.getElementById('zip-meta');
const photoInput = document.getElementById('photo');
const photoPick = document.getElementById('photo-pick');
const photoDrop = document.getElementById('photo-drop');
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.
const ERROR_MESSAGES = {
// Step 1 — validation
invalid_type: 'Pick one of: skill, agent, or plugin.',
zip_invalid: 'That file isn\'t a valid ZIP archive.',
zip_unsafe_path: 'The ZIP contains a path that escapes the archive — refuse to extract.',
zip_missing_skill_md: 'A Skill ZIP must contain a SKILL.md file.',
zip_missing_agent_md_with_frontmatter:
'An Agent ZIP must contain a Markdown file with name + description in YAML frontmatter.',
zip_missing_claude_plugin_json:
'A Plugin ZIP must contain a .claude-plugin/plugin.json file at its root.',
plugin_json_invalid: 'The .claude-plugin/plugin.json file is not valid JSON.',
zip_looks_like_skill:
'This ZIP contains a SKILL.md — looks like a Skill, not an Agent. Switch the type to Skill or remove SKILL.md.',
zip_looks_like_plugin:
'This ZIP contains a .claude-plugin/plugin.json — looks like a Plugin, not the type you picked. Switch the type to Plugin.',
// Step 1 + 2 — file size
file_too_large: 'File too large — max 50 MB for the ZIP, 5 MB for photos, 10 MB per doc.',
// Step 2 — metadata
missing_name:
'Name is required. Either fill it in here or add a `name:` field to your frontmatter.',
invalid_name_format:
'Name must be lowercase letters, digits, and hyphens only (max 64 characters).',
invalid_category: 'Pick a category from your own groups, or leave it blank.',
invalid_email:
'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.',
// Other
entity_not_found: 'That entity no longer exists.',
not_owner: 'You don\'t own this entity, so you can\'t change it.',
};
function _renderManifestIssues(lines, manifest) {
const issues = (manifest && manifest.issues) || [];
for (const m of issues.slice(0, 5)) {
lines.push('• manifest: ' + m);
}
if (issues.length > 5) lines.push(' …and ' + (issues.length - 5) + ' more.');
}
function _renderContentIssues(lines, content) {
const issues = (content && content.issues) || [];
if (!issues.length) return;
// Plain-language labels — match the server-side rendering in
// _content_findings.html so the wording stays consistent.
const FIELD_LABEL = {
'frontmatter.description': 'Description (top of the file, after `description:`)',
'plugin.json.description': 'Description (in `.claude-plugin/plugin.json`)',
'description': 'Description (on the upload form)',
'body': 'Content (rest of the file after the description line)',
};
const CODE_LABEL = {
'empty': 'is missing',
'too_short': 'is too short',
'low_word_count': 'needs more distinct words',
'placeholder_text': 'still has placeholder text (TODO, template, etc.)',
'body_too_short': 'is too short',
};
const COMPONENT_LABEL = {
'skill': 'skill', 'agent': 'agent', 'plugin': 'plugin',
'command': 'command', 'submission': 'description',
};
lines.push('');
lines.push('What needs fixing:');
for (const issue of issues.slice(0, 6)) {
const comp = COMPONENT_LABEL[issue.component_type] || 'component';
const fieldLabel = FIELD_LABEL[issue.field] || issue.field || 'description';
const codeLabel = CODE_LABEL[issue.code] || (issue.code || 'issue').replace(/_/g, ' ');
const where = issue.component_type === 'submission'
? 'Description on the upload form'
: (comp.charAt(0).toUpperCase() + comp.slice(1))
+ (issue.name ? ' — ' + issue.name : '');
lines.push('• ' + where);
lines.push(' ' + fieldLabel + ' ' + codeLabel + '.');
if (issue.hint) lines.push(' ' + issue.hint);
}
if (issues.length > 6) {
lines.push(' …and ' + (issues.length - 6) + ' more.');
}
}
function _renderSecurityFindings(lines, staticSecurity) {
const findings = (staticSecurity && staticSecurity.findings) || [];
for (const f of findings.slice(0, 5)) {
const where = (f.file || '?') + ':' + (f.line || '?');
lines.push('• ' + where + ' — ' + (f.reason || f.category || 'security finding'));
}
if (findings.length > 5) lines.push(' …and ' + (findings.length - 5) + ' more.');
}
function humanizeError(detail) {
if (!detail) return 'Something went wrong. Please try again.';
// Structured detail (FastAPI wraps the dict at .detail). Shapes:
// {code: "validation_failed", checks: {manifest, content, quality}} — manifest/content fail
// {code: "security_blocked", checks: {static_security}} — static_scan deny-list hit
// {code: "submission_blocked", entity_id, checks: {...}} — legacy, kept for back-compat
// {code: "quota_exceeded", limit, blocked_in_last_24h, hint} — LLM-tier spam quota
if (typeof detail === 'object') {
const code = detail.code || '';
const checks = detail.checks || {};
if (code === 'validation_failed') {
const lines = ['Bundle needs fixing before it can be submitted.'];
_renderManifestIssues(lines, checks.manifest);
_renderContentIssues(lines, checks.content);
lines.push('');
lines.push('Fix the issues above and try again. Nothing was uploaded.');
lines.push('See /store/examples for full before/after examples (opens in new tab).');
return lines.join('\n');
}
if (code === 'security_blocked') {
const lines = ['Upload blocked: security review found risky patterns in the bundle.'];
_renderSecurityFindings(lines, checks.static_security);
lines.push('');
lines.push('Nothing was uploaded. Remove the flagged code or secrets and try again.');
lines.push('If a finding is a false positive, contact your administrator.');
return lines.join('\n');
}
if (code === 'submission_blocked') {
// Legacy server response (pre-cutover). Render same payload shape
// so refreshed pages on an older deploy still get a usable banner.
const lines = ['Upload blocked by automated checks.'];
_renderSecurityFindings(lines, checks.static_security);
_renderManifestIssues(lines, checks.manifest);
_renderContentIssues(lines, checks.content);
lines.push('');
lines.push('Fix the issues above and re-upload as a new version.');
lines.push('See /store/examples for full before/after examples (opens in new tab).');
return lines.join('\n');
}
if (code === 'quota_exceeded') {
return 'Upload blocked: too many rejected uploads in the last 24 hours '
+ '(' + (detail.blocked_in_last_24h || '?') + '/' + (detail.limit || '?') + '). '
+ (detail.hint || 'Wait for the window to reset.');
}
if (code) return 'Upload failed: ' + code;
// Unknown object shape — last resort, JSON-stringify so the user
// sees the raw payload instead of [object Object].
try { return 'Upload failed: ' + JSON.stringify(detail); }
catch (_) { return 'Upload failed.'; }
}
const s = String(detail);
if (ERROR_MESSAGES[s]) return ERROR_MESSAGES[s];
// Codes with a parameter (e.g. "unknown_type:foo")
const head = s.split(':', 1)[0];
if (ERROR_MESSAGES[head]) return ERROR_MESSAGES[head];
// Last resort — show a friendly wrapper plus the raw token.
return 'Upload failed: ' + s;
}
const bannerActions = document.getElementById('banner-actions');
// Manual toggle for the "Before you upload" disclosure. Native <details>
// behaviour was unreliable in at least one operator browser — explicit
// button + aria-expanded keeps the toggle deterministic. Renders on both
// step 1 and step 2 so the bar stays visible after Next.
function setGuidelinesOpen(btn, body, open) {
btn.setAttribute('aria-expanded', String(open));
body.hidden = !open;
}
document.querySelectorAll('.guidelines-toggle').forEach((btn) => {
const bodyId = btn.getAttribute('aria-controls');
const body = bodyId ? document.getElementById(bodyId) : null;
if (!body) return;
btn.addEventListener('click', () => {
const isOpen = btn.getAttribute('aria-expanded') === 'true';
setGuidelinesOpen(btn, body, !isOpen);
});
});
// "See Before you upload" inline link below the description textarea —
// open the step-2 disclosure programmatically so the user lands with
// the panel already expanded.
const openGuidelinesLink = document.getElementById('open-guidelines-link');
if (openGuidelinesLink) {
openGuidelinesLink.addEventListener('click', (e) => {
e.preventDefault();
const btn = document.getElementById('guidelines-toggle-2');
const body = document.getElementById('guidelines-body-2');
if (btn && body) {
setGuidelinesOpen(btn, body, true);
btn.scrollIntoView({behavior: 'smooth', block: 'start'});
}
});
}
function showError(msg, {showExamplesLink = false} = {}) {
banner.className = 'banner error';
banner.firstElementChild.textContent = '!';
bannerText.textContent = msg;
banner.hidden = false;
bannerActions.hidden = !showExamplesLink;
// Scroll the banner into view — the Finish button lives at the bottom
// of step 2, so a rejection banner that flips on at the top is
// off-screen on a tall form. Submitter needs to SEE the issues to
// act on them.
banner.scrollIntoView({behavior: 'smooth', block: 'start'});
}
function clearBanner() { banner.hidden = true; bannerActions.hidden = true; }
function showStep(n) {
document.getElementById('step-1').classList.toggle('is-active', n === 1);
document.getElementById('step-2').classList.toggle('is-active', n === 2);
document.getElementById('seg-2').classList.toggle('is-done', n >= 2);
clearBanner();
window.scrollTo({top: 0, behavior: 'smooth'});
}
// Type tiles
document.querySelectorAll('#type-tiles label').forEach(lbl => {
lbl.addEventListener('click', () => {
document.querySelectorAll('#type-tiles label').forEach(l => l.classList.remove('is-active'));
lbl.classList.add('is-active');
});
});
// Generic drop-zone wiring — used for both the ZIP archive and the cover
// photo. The two differ only in their accepted MIME/extension and the
// onAccept callback that updates page state.
function wireDropZone(dropEl, fileInput, pickBtn, validate, onAccept) {
// Click anywhere on the zone (except the explicit button — which already
// fires its own click) opens the file picker.
pickBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });
dropEl.addEventListener('click', (e) => {
if (e.target.tagName !== 'BUTTON') fileInput.click();
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) handle(fileInput.files[0]);
});
function handle(f) {
const err = validate(f);
if (err) { showError(err); return; }
// Sync the <input> so the form-submit fallback works too.
try {
const dt = new DataTransfer();
dt.items.add(f);
fileInput.files = dt.files;
} catch (_) {}
dropEl.classList.remove('is-dragover');
onAccept(f);
clearBanner();
}
['dragenter', 'dragover'].forEach(evt => {
dropEl.addEventListener(evt, (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
dropEl.classList.add('is-dragover');
});
});
['dragleave', 'dragend'].forEach(evt => {
dropEl.addEventListener(evt, (e) => {
// dragleave fires when entering a child; only un-highlight when the
// pointer actually leaves the drop zone.
if (evt === 'dragleave' && dropEl.contains(e.relatedTarget)) return;
dropEl.classList.remove('is-dragover');
});
});
dropEl.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropEl.classList.remove('is-dragover');
const file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (file) handle(file);
});
}
// Page-level guard: dropping anywhere outside the zones shouldn't navigate
// away from the page (browser default behavior for files).
['dragover', 'drop'].forEach(evt => {
window.addEventListener(evt, (e) => { e.preventDefault(); });
});
// ZIP zone
wireDropZone(
zipDrop, zipInput, zipPick,
(f) => /\.zip$/i.test(f.name) ? null : 'Only .zip files are accepted.',
(f) => {
zipFile = f;
zipDrop.classList.add('has-file');
zipLabel.innerHTML = 'Selected:<span class="filename">' + escapeHtml(f.name) + '</span>';
zipMeta.textContent = formatSize(f.size);
},
);
// Photo zone — accepts JPG / PNG / WebP up to 5 MB.
const PHOTO_MAX = 5 * 1024 * 1024;
wireDropZone(
photoDrop, photoInput, photoPick,
(f) => {
if (!/^image\/(jpeg|png|webp)$/i.test(f.type) &&
!/\.(jpe?g|png|webp)$/i.test(f.name)) {
return 'Photo must be a JPG, PNG, or WebP image.';
}
if (f.size > PHOTO_MAX) {
return 'Photo too large — max 5 MB.';
}
return null;
},
(f) => {
photoDrop.classList.add('has-file');
photoLabel.innerHTML = 'Selected:<span class="filename">' + escapeHtml(f.name) + '</span>';
},
);
// Docs
// v32: client-side allowlist sanity check. The same allowlist is enforced
// server-side (returns HTTP 415), so this is purely UX — give the user a
// useful inline message before they submit instead of a generic error after
// the round-trip.
const ALLOWED_DOC_EXT = new Set(['.pdf', '.md', '.markdown', '.txt']);
addDocBtn.addEventListener('click', () => docInput.click());
docInput.addEventListener('change', () => {
for (const f of docInput.files) {
const lower = (f.name || '').toLowerCase();
const dot = lower.lastIndexOf('.');
const ext = dot >= 0 ? lower.slice(dot) : '';
if (!ALLOWED_DOC_EXT.has(ext)) {
alert(
'Unsupported document format: "' + f.name + '". '
+ 'Only PDF (.pdf), Markdown (.md, .markdown), and plain text (.txt) are accepted.'
);
continue;
}
docs.push(f);
}
docInput.value = '';
renderDocs();
});
function renderDocs() {
docList.innerHTML = '';
docs.forEach((f, i) => {
const item = document.createElement('div');
item.className = 'doc-item';
item.innerHTML = `
<span class="name">${escapeHtml(f.name)}</span>
<span class="size">${formatSize(f.size)}</span>
<button type="button" data-i="${i}" title="Remove">✕</button>`;
docList.appendChild(item);
});
docList.querySelectorAll('button[data-i]').forEach(btn => {
btn.addEventListener('click', () => {
docs.splice(parseInt(btn.dataset.i, 10), 1);
renderDocs();
});
});
}
// Description char counter — turns green at the configured floor so the
// submitter gets immediate feedback that they're past the bar. The server
// is the source of truth; this is purely UX. Floor is operator-configurable
// via /admin/server-config (default 60).
const DESC_MIN = {{ guardrail.min_description_chars|default(60) }}; // Live config from /admin/server-config
const descField = document.getElementById('description');
const descCounter = document.getElementById('desc-counter');
function updateDescCounter() {
const n = (descField.value || '').trim().length;
descCounter.textContent = `${n} / ${DESC_MIN} minimum`;
descCounter.classList.toggle('ok', n >= DESC_MIN);
descCounter.classList.toggle('warn', n > 0 && n < DESC_MIN);
}
descField.addEventListener('input', updateDescCounter);
// Component preview rendering. Called after /preview with the
// `components` array — shows green/red dots per component description.
// Same plain-language maps used by humanizeError above. Kept inline
// here so the renderComponents preview shows the same wording as the
// rejection banner.
const FIELD_LABEL_PREVIEW = {
'frontmatter.description': 'Description (top of the file)',
'plugin.json.description': 'Description (in plugin.json)',
'description': 'Description',
'body': 'Content (rest of the file)',
};
const CODE_LABEL_PREVIEW = {
'empty': 'is missing',
'too_short': 'is too short',
'low_word_count': 'needs more distinct words',
'placeholder_text': 'still has placeholder text',
'body_too_short': 'is too short',
};
function renderComponents(components) {
const wrap = document.getElementById('comp-preview');
const list = document.getElementById('comp-list');
list.innerHTML = '';
if (!components || components.length === 0) {
wrap.hidden = true;
return;
}
for (const c of components) {
const row = document.createElement('div');
row.className = 'comp-row';
const dot = document.createElement('div');
dot.className = 'comp-dot ' + (c.ok ? 'ok' : 'bad');
const text = document.createElement('div');
text.className = 'comp-text';
const fileLine = document.createElement('div');
const fileSpan = document.createElement('span');
fileSpan.className = 'file';
fileSpan.textContent = c.file || '';
const typeBadge = document.createElement('span');
typeBadge.className = 'type';
typeBadge.textContent = c.type;
fileLine.appendChild(fileSpan);
fileLine.appendChild(typeBadge);
text.appendChild(fileLine);
if (c.ok && c.description) {
const preview = document.createElement('div');
preview.className = 'preview';
preview.textContent = c.description;
text.appendChild(preview);
}
if (!c.ok) {
for (const issue of (c.issues || []).slice(0, 3)) {
const issLine = document.createElement('div');
issLine.className = 'issue';
const fieldLabel = FIELD_LABEL_PREVIEW[issue.field]
|| issue.field || 'Description';
const codeLabel = CODE_LABEL_PREVIEW[issue.code]
|| (issue.code || 'issue').replace(/_/g, ' ');
issLine.textContent = fieldLabel + ' ' + codeLabel + '. ' + (issue.hint || '');
text.appendChild(issLine);
}
// Single "See example ↗" link per component pointing at the
// matching anchor on /store/examples.
const anchor = c.type || 'skill';
const linkRow = document.createElement('div');
linkRow.style.marginTop = '4px';
const link = document.createElement('a');
link.href = '/store/examples#' + anchor;
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'See ' + anchor + ' example ↗';
link.style.fontSize = '12px';
link.style.color = 'var(--primary, #0073D1)';
link.style.textDecoration = 'underline';
linkRow.appendChild(link);
text.appendChild(linkRow);
}
row.appendChild(dot);
row.appendChild(text);
list.appendChild(row);
}
wrap.hidden = false;
}
// Step 1 → preview
nextBtn.addEventListener('click', async () => {
clearBanner();
if (!zipFile) { showError('Please choose a ZIP file.'); return; }
const type = document.querySelector('input[name=type]:checked').value;
nextBtn.disabled = true;
nextBtn.textContent = 'Validating…';
try {
const fd = new FormData();
fd.append('file', zipFile);
fd.append('type', type);
const res = await fetch('/api/store/entities/preview', {method: 'POST', body: fd});
if (!res.ok) {
let msg = 'Validation failed.';
try {
const j = await res.json();
if (j.detail) msg = humanizeError(j.detail);
} catch(_) {}
showError(msg);
return;
}
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) {
showError(String(err));
} finally {
nextBtn.disabled = false;
nextBtn.textContent = 'Next →';
}
});
backBtn.addEventListener('click', () => showStep(1));
// Step 2 → final create
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;
// Show the "see examples" link when content-tier issues are the
// dominant fix needed — both new + legacy codes can carry them.
const isFixable = detail.code === 'validation_failed'
|| detail.code === 'submission_blocked';
return isFixable
&& detail.checks.content
&& (detail.checks.content.issues || []).length > 0;
}
const type = document.querySelector('input[name=type]:checked').value;
finishBtn.disabled = true;
finishBtn.textContent = 'Uploading…';
try {
const fd = new FormData();
fd.append('file', zipFile);
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();
if (vurl) fd.append('video_url', vurl);
const photo = document.getElementById('photo').files[0];
if (photo) fd.append('photo', photo);
for (const d of docs) fd.append('docs', d);
const res = await fetch('/api/store/entities', {method: 'POST', body: fd});
if (res.ok) {
const entity = await res.json();
window.location = `/marketplace/flea/${encodeURIComponent(entity.id)}`;
return;
}
// Inline failures (validation_failed / security_blocked) are hard
// rejections — no entity row, no bundle on disk. Render the banner
// inline and stay on step 2 so the submitter can fix and retry.
//
// The legacy ``submission_blocked`` branch is retained for one
// release cycle. Older deploys may still emit it with an
// ``entity_id`` pointing at a hidden row; redirect to the detail
// page so the existing quarantine banner UX still works. Fresh
// deploys never hit this branch.
let msg = 'Upload failed.';
let showLink = false;
try {
const j = await res.json();
const detail = j && j.detail;
const eid = detail && detail.entity_id;
if (eid && detail.code === 'submission_blocked') {
window.location = `/marketplace/flea/${encodeURIComponent(eid)}`;
return;
}
if (detail) {
msg = humanizeError(detail);
showLink = isContentBlock(detail);
}
} catch(_) {}
showError(msg, {showExamplesLink: showLink});
} catch (err) {
showError(String(err));
} finally {
finishBtn.disabled = false;
finishBtn.textContent = 'Finish';
}
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
}
function formatSize(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
</script>
{% endblock %}