"""Naming helpers for Store entities. The marketplace served to Claude Code is flat: skill / agent / plugin names must be globally unique within a user's view, otherwise Claude Code resolves the second-loaded entity over the first. To prevent collisions across different Store owners uploading entities with the same display name, every Store-derived plugin is suffixed with the owner's sanitized email-local-part (``-by-``) at upload time. The username is **snapshotted on the entity row** at upload — it does not auto-update if the owner's email changes later. Per product spec, emails are stable in this deployment; we don't refactor on email rename. """ from __future__ import annotations import hashlib import re from pathlib import Path from typing import Iterable _SANITIZE_RE = re.compile(r"[^a-z0-9-]+") _DASH_COLLAPSE_RE = re.compile(r"-+") def sanitize_username(email: str) -> str: """Convert an email to a Claude-Code-safe username slug. Takes the local-part (everything before the first ``@``), lowercases it, replaces every run of non-``[a-z0-9-]`` characters with a single ``-``, collapses repeats, and trims leading/trailing dashes. sanitize_username("alice_smith@example.com") -> "alice-smith" sanitize_username("john.doe+claude@acme.com") -> "john-doe-claude" sanitize_username("USER@example.com") -> "user" Raises ``ValueError`` if the local-part sanitizes to an empty string — callers (the upload endpoint) translate that to a 400. Note: this mapping is **many-to-one** — ``alice.smith@x`` and ``alice_smith@x`` both yield ``alice-smith``. The Store namespace is flat in Claude Code, so two such users uploading entities with the same display name would produce identical ``-by-`` suffixes and collide in the served marketplace + bundle. The upload endpoint enforces global uniqueness on the suffixed value via ``app.api.store._suffixed_already_taken`` and rejects the second one with 409 ``conflict_global_suffix``; the per-owner UNIQUE on ``store_entities(owner_user_id, name)`` alone does not catch this. """ local = email.split("@", 1)[0].lower() s = _SANITIZE_RE.sub("-", local) s = _DASH_COLLAPSE_RE.sub("-", s).strip("-") if not s: raise ValueError(f"email local-part sanitizes to empty: {email!r}") return s def suffixed_name(original_name: str, username: str) -> str: """``-by-`` — the display+invocation name baked into Store-derived plugin/skill/agent files at upload time. """ return f"{original_name}-by-{username}" # v36+: archive renames the entity's `name` to free the (owner, name) # slot and the global suffix slot for re-upload. The marker is a # fixed-token plus epoch suffix so display-strip and is-archived # detection are deterministic. Uploaders are blocked from picking # a literal name matching this pattern via _NAME_RE in # app/api/store.py. _ARCHIVE_MARKER = "__archived__" _ARCHIVE_NAME_RE = re.compile(rf"{_ARCHIVE_MARKER}\d+$") def make_archive_name(original_name: str, archived_at_epoch: int) -> str: """Compute the suffixed name to write into ``store_entities.name`` when an entity transitions to ``visibility_status='archived'``. The suffix frees the per-owner (owner_user_id, name) UNIQUE slot AND the global ``-by-`` slug slot, so the owner can re-upload under the original name without picking a new one. Existing installers see the renamed slug on the next sync. """ return f"{original_name}{_ARCHIVE_MARKER}{int(archived_at_epoch)}" def is_archived_name(name: str) -> bool: """Whether ``name`` carries the archive-rename suffix.""" return bool(_ARCHIVE_NAME_RE.search(name or "")) def strip_archive_suffix(name: str) -> str: """Return the display form of a possibly-archived ``name``. No-op when the input doesn't carry the archive suffix. Used by admin queue + my-stack templates so the user-facing label shows the original name with an "Archived" badge instead of the ugly suffix. """ if not name: return name return _ARCHIVE_NAME_RE.sub("", name) def compute_entity_version(plugin_dir: Path) -> str: """Content-addressed version for a Store entity's plugin tree. Hashes every regular file under ``plugin_dir`` in sorted-relative-path order, including each file's relative path in the digest so that a rename counts as a content change. Returns the first 16 hex chars of the SHA-256 — short enough for human-readable use in plugin.json ``version`` and audit messages, long enough to be collision-free in practice. """ h = hashlib.sha256() for f in _iter_files(plugin_dir): rel = f.relative_to(plugin_dir).as_posix() h.update(rel.encode("utf-8")) h.update(b"\x00") h.update(f.read_bytes()) h.update(b"\x00") return h.hexdigest()[:16] 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())