Claude Skill

lov-maintain-partners

Maintain the Skill Publisher website's partners section AND align partner logo rows on event posters / hero strips: reuse lov-find-logo for brand logo discovery, normalize collected logos to a 240px-tall content canvas (retina-ready), rasterize SVGs via rsvg-convert before normal

LLM Mart · 0 points · 12 views 30 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lovstudio-skills-skills_maintain-partners-0b16007.zip · 20 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/maintain-partners
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
Git git clone https://github.com/lovstudio/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.

README

伙伴名录 · Partner Directory

Version

Maintain the Skill Publisher website's "Trusted By" partners section: collect brand logos through lov-find-logo, normalize to the 80px canvas, append entries with i18n taglines across 4 locales, and audit for dead URLs / missing assets.

Part of skills — by example.com

Install

SKILLS_DIR="${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}"
git clone https://example.com/skills/maintain-partners-skill "$SKILLS_DIR/lov-maintain-partners"
git clone https://example.com/skills/find-logo-skill "$SKILLS_DIR/lov-find-logo"
python3 -m pip install Pillow
brew install librsvg  # for SVG logo sources

Configuration

Set the website repo root with --repo, SKILL_MAINTAIN_PARTNERS_SITE_ROOT, or the shared profile at ${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json}. SKILL_WEB_ROOT and PARTNERS_SITE_ROOT remain accepted as legacy aliases.

Set the PARTNERS TSX file with --partners-file, SKILL_MAINTAIN_PARTNERS_FILE, or profile keys. The default checks app/(main)/(home)/PartnersGrid.tsx first, then legacy app/(main)/(home)/WorkshopDispatch.tsx. SKILL_PARTNERS_FILE and PARTNERS_FILE remain accepted as legacy aliases.

Supported profile keys:

{
  "sites": {
    "skill-publisher_web": "$HOME/projects/my-site",
    "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
  },
  "skill-publisher": {
    "web_root": "$HOME/projects/my-site",
    "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
  },
  "workspace": {
    "web_root": "$HOME/projects/my-site",
    "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
  }
}

What it does

The Skill Publisher homepage runs a "Trusted By" strip that renders 30+ partner logos against a grayscale opacity-60 filter. Maintaining it means three recurring tasks:

  1. Collecting — invoking lov-find-logo to pull and archive brand logos.
  2. Normalizing — every logo must trim to its content bbox and resize to exactly 80px tall so the strip looks even. White-on-transparent logos must be inverted so they show on the light background.
  3. Wiring — appending the partner to PARTNERS in the configured partners TSX file and adding a partner*Tagline key to all 4 locale JSONs (zh-CN / en / ja / th).

Logo discovery is delegated to lov-find-logo; this skill does not keep its own homepage crawler or fallback scraper.

This skill is three single-file Python CLIs plus an AI workflow that orchestrates them.

Scripts

normalize_logo.py   Trim, optional inversion, resize to 80px, write PNG
add_partner.py      Append to PARTNERS array + i18n JSONs (idempotent)
audit_partners.py   Walk PARTNERS; report missing logos / i18n keys / dead URLs

Quick examples

# Collect a logo through the required find-logo skill
SKILL_ROOT="${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}"
WEB_ROOT="${SKILL_MAINTAIN_PARTNERS_SITE_ROOT:?Set this or pass --repo}"
PARTNERS_TSX="${SKILL_MAINTAIN_PARTNERS_FILE:-app/(main)/(home)/PartnersGrid.tsx}"

python3 "$SKILL_ROOT/lov-find-logo/scripts/find_logo.py" \
  --name "Example" --url https://example.com --slug example --json

# Normalize: auto-invert white-on-transparent
normalize_logo.py --src ~/.skill-publisher/logo-collection/example/logo.png \
                  --dst "$WEB_ROOT/public/partners/example/logo.png"

# Add to PARTNERS + i18n
add_partner.py --repo "$WEB_ROOT" \
               --partners-file "$PARTNERS_TSX" \
               --name "Example" --href "https://example.com" \
               --logo "/partners/example/logo.png" \
               --key partnerExampleTagline \
               --category community \
               --zh "Example · 一句话定位" \
               --en "Example · one-line positioning" \
               --ja "Example · 一行紹介" \
               --th "Example · บรรยายหนึ่งบรรทัด"

# Audit (use --probe to HTTP-check every URL)
audit_partners.py --repo "$WEB_ROOT" --partners-file "$PARTNERS_TSX" --probe

Repo layout assumed

<web-root>/
├── app/(main)/(home)/PartnersGrid.tsx       ← PARTNERS array
├── public/partners/<slug>/logo.png          ← logo files
└── src/i18n/messages/
    ├── zh-CN.json    ← dispatch.partner*Tagline
    ├── en.json
    ├── ja.json
    └── th.json

If you fork this for another site, edit the constants at the top of each script.

License

MIT

Skill manifest

伙伴名录 · Partner Directory

Maintains the configured website repo. Resolve the path from --repo, SKILL_MAINTAIN_PARTNERS_SITE_ROOT, or the shared user profile. The partners strip usually lives in app/(main)/(home)/PartnersGrid.tsx as a PARTNERS: Partner[] array; older sites may still keep it in app/(main)/(home)/WorkshopDispatch.tsx. Logos live in public/partners/<slug>/logo.png; taglines in src/i18n/messages/{zh-CN,en,ja,th}.json under dispatch.partner*Tagline.

User Configuration

Before touching files, resolve:

SKILL_ROOT="${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}"
SKILL_DIR="${SKILL_DIR:-$SKILL_ROOT/lov-maintain-partners}"
WEB_ROOT="${SKILL_MAINTAIN_PARTNERS_SITE_ROOT:?Set this or pass --repo}"
PARTNERS_TSX="${SKILL_MAINTAIN_PARTNERS_FILE:-app/(main)/(home)/PartnersGrid.tsx}"

Use this precedence for the website root:

  1. Explicit --repo <path> on add_partner.py / audit_partners.py.
  2. SKILL_MAINTAIN_PARTNERS_SITE_ROOT.
  3. Shared profile JSON at ${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json}.

SKILL_WEB_ROOT and PARTNERS_SITE_ROOT are accepted as legacy aliases, but should not be the public contract for reusable skills.

Use this precedence for the partners TSX file:

  1. Explicit --partners-file <path>.
  2. SKILL_MAINTAIN_PARTNERS_FILE.
  3. Shared profile keys sites.partners_file, skill-publisher.partners_file, partners.file, or workspace.partners_file.
  4. app/(main)/(home)/PartnersGrid.tsx, then legacy app/(main)/(home)/WorkshopDispatch.tsx.

SKILL_PARTNERS_FILE and PARTNERS_FILE are accepted as legacy aliases, but should not be the public contract for reusable skills.

For details and supported profile keys, read references/user-config.md.

Skill Dependencies

  • lov-find-logo is required for all logo discovery. This skill must not scrape homepages itself or keep a separate fallback crawler.
  • Use the depends_on frontmatter field to declare skill-level dependencies. This mirrors the depends_on field in lov-general-skills/skills.yaml; unknown frontmatter keys are expected to be ignored by agents that do not consume dependency metadata.

When to Use

  • User asks to add one or more new partners (with or without a logo URL).
  • User asks to standardize / normalize a logo (sizing wrong, white-on-white, etc.).
  • User provides a local file and asks to replace an existing partner's logo.
  • User asks to audit the partners section before a release.

Standards

  • Logo canvas: 80px content height for the website partners strip (light grayscale, CSS height: 32px ≈ 2.5× density, sharp enough), 240px for event posters or any retina export at scale: 2 or higher.
  • For white-on-transparent logos: invert (full or selective) so they show on the light grayscale strip.
  • For icon-only logos < ~40px wide after normalization: pass --show-name when adding so the brand name renders next to the icon.
  • Tagline format: <品牌名> · <一句话定位> in Chinese; mirror style in en/ja/th.

Workflow

Op 1: Add a new partner

  1. Ask the user for the brand name + homepage URL via AskUserQuestion.
  2. Collect the logo with lov-find-logo:
    python3 "$SKILL_ROOT/lov-find-logo/scripts/find_logo.py" \
      --name "<显示名>" --url <URL> --slug <slug> --json
    
    Use the archived primary asset under ~/.skill-publisher/logo-collection/<slug>/logo.<ext>. If find_logo.py returns no candidates, stop and ask the user for a better official URL / press-kit URL, then rerun find_logo.py. Do not call a local scraper from this skill.
  3. Visually verify the archived primary asset before normalizing.
  4. If the primary asset is SVG, rasterize it before normalization:
    rsvg-convert -h 240 ~/.skill-publisher/logo-collection/<slug>/logo.svg \
      -o /tmp/<slug>-raw.png
    
    Use the rasterized /tmp/<slug>-raw.png as --src. For non-SVG sources, use the archived primary asset directly.
  5. Normalize:
    python3 "$SKILL_DIR/scripts/normalize_logo.py" \
      --src <archived-or-rasterized-logo> \
      --dst "$WEB_ROOT/public/partners/<slug>/logo.png" \
      --invert auto
    
  6. Read the normalized PNG to confirm it's visible (not white-on-white).
  7. Append to PARTNERS + all 4 locale JSONs:
    python3 "$SKILL_DIR/scripts/add_partner.py" \
      --repo "$WEB_ROOT" \
      --partners-file "$PARTNERS_TSX" \
      --name "<显示名>" --href "<URL>" \
      --logo "/partners/<slug>/logo.png" \
      --key partner<Slug>Tagline \
      --category community \
      --zh "..." --en "..." --ja "..." --th "..." \
      [--show-name]
    

Op 2: Normalize an existing logo

python3 "$SKILL_DIR/scripts/normalize_logo.py" \
  --src public/partners/<slug>/logo.png \
  --dst public/partners/<slug>/logo.png \
  --invert auto

Re-read after to verify.

Op 3: Replace logo from a user-provided file

Ask for the source file path directly, or read it from the user's configured workspace/profile. Do not assume a private partners folder.

python3 "$SKILL_DIR/scripts/normalize_logo.py" \
  --src "<user-provided path>" \
  --dst "$WEB_ROOT/public/partners/<slug>/logo.png" \
  --invert auto

JPEG inputs auto-strip near-white background to transparent before crop.

Op 4: Audit

python3 "$SKILL_DIR/scripts/audit_partners.py" \
  --repo "$WEB_ROOT" \
  --partners-file "$PARTNERS_TSX"
# add --probe to also HTTP-check every href (slow, requires proxy)

Reports: missing logo files, missing i18n keys per locale, dead URLs.

Op 5: Align a row of partner logos (cross-asset visual height parity)

When: putting 3+ partner logos in a single horizontal strip and they look different sizes despite having the same CSS height. Common in event posters, hero sections, "联办 / co-host" rows.

Root cause: each source file has different internal padding (designer canvas margin), so two PNGs both set to height: 24px render at different visible heights because their content occupies different fractions of the canvas. Per-logo CSS height tweaks based on eyeballed content ratios are unstable—different displays / scaling will diverge again.

Reliable fix — trim at file level, uniform CSS box:

  1. Normalize every logo to identical content height. Default raster file target is 240px (3× density for retina poster export at scale: 2; 80px gives only 1.7× and looks soft after PNG export). Use --invert off if the source is already light-on-transparent (don't double-invert):

    for f in lujiazui juanyi citic-bookstore citic-thinker-lab; do
      python3 "$SKILL_DIR/scripts/normalize_logo.py" \
        --src "<configured-partners-source>/<brand>/<file>.png" \
        --dst <event-assets>/partners/$f.png \
        --height 240 --invert auto
    done
    

    Always normalize from the original source, never from a previously normalized 80px file (upscaling = blurry — burned by this on juanyi).

  2. For SVG sources, rasterize first. normalize_logo.py operates on raster pixels and cannot crop SVG viewBox padding. Without this step an SVG always renders smaller than rasterized PNG siblings:

    rsvg-convert -h 720 brand.svg -o /tmp/brand-raw.png   # 3× of 240
    python3 "$SKILL_DIR/scripts/normalize_logo.py" \
      --src /tmp/brand-raw.png --dst <event-assets>/partners/brand.png \
      --height 240 --invert off
    

    rsvg-convert ships with librsvg (brew install librsvg).

  3. For SVG with embedded background rect (icon wrapped in a black/colored rounded square — common in app-icon-style SVGs from find-logo), strip the background before rasterizing, otherwise filter brightness(0) invert(1) flattens it into a solid white block that hides the icon:

    # Drop the outer <rect fill="#000"...> wrapper
    sed -E 's|<rect[^/]*fill="#0+"[^/]*/>||' brand.svg > /tmp/brand-clean.svg
    rsvg-convert -h 720 /tmp/brand-clean.svg -o /tmp/brand-raw.png
    
  4. Wrap each logo in a fixed-size box (recommended over auto-width flex):

    <span class="ps-logo-box"><img src="..." class="ps-logo"></span>
    
    .ps-logo-box {
      width: 96px; height: 30px;             /* fixed grid cell */
      display: inline-flex;
      align-items: center; justify-content: center;
      border: 1px solid rgba(255,255,255,0.10);
      border-radius: 4px;
      padding: 3px 6px;
      box-sizing: border-box;
    }
    .ps-logo { max-width: 100%; max-height: 100%; width: auto; height: auto; display: block; }
    

    Fixed boxes give a stable matrix look — narrow logos (icon-only) and wide logos (icon + wordmark) all occupy the same footprint, with the asset scaled to fit. Auto-width flex (the older recipe) makes per-row total widths unpredictable as logos get added/removed.

  5. Dark-background unification — when the row sits on a dark canvas (e.g. event poster), most brand logos are designed for white BG and look inconsistent (some have black text, some have brand-colored marks). The stable recipe:

    .ps-logo { filter: brightness(0) invert(1) opacity(0.88); }
    /* logos already white-on-transparent — opt out of inversion */
    .ps-logo.ps-logo-original { filter: opacity(0.88); }
    

    brightness(0) flattens all colors to black, then invert(1) produces uniform white at the configured opacity. The .ps-logo-original escape hatch is for source files that are already white-on-transparent (white SVG variants from a brand kit) so you don't double-process them into invisible black-on-dark.

  6. Icon-only SVG → composite icon + wordmark — if the brand SVG only has an icon (no "BrandName" wordmark beside it), don't ship just the icon in a 96×30 box (it'll look like an unidentified mark). Compose the wordmark with PIL using the brand's own font when possible:

    from PIL import Image, ImageDraw, ImageFont, ImageOps
    # 1. rasterize cleaned SVG, invert white→black so default filter works
    icon = Image.open('/tmp/brand-icon.png').convert('RGBA')
    r, g, b, a = icon.split()
    inv = Image.merge('RGB', (ImageOps.invert(r), ImageOps.invert(g), ImageOps.invert(b)))
    icon = Image.merge('RGBA', (*inv.split(), a))
    icon = icon.crop(icon.getbbox())
    target_h = 240
    icon = icon.resize((int(icon.width * target_h / icon.height), target_h), Image.LANCZOS)
    # 2. render wordmark in brand font (find-logo bundles fonts/ when found)
    font = ImageFont.truetype('partners/<brand>/fonts/<Family>.ttf', 150)
    # 3. compose icon + gap + text on transparent canvas
    

    The PNG goes through the same brightness(0) invert(1) filter as raster logos — match colors with all other entries automatically. Use the brand's own font (often shipped under <brand>/fonts/ by the find-logo skill); fall back to system SF / Helvetica only if no brand font is available.

  7. Anti-pattern — do not try to fix alignment by setting per-logo heights like .ps-logo-juanyi { height: 26px }. It's brittle (every new logo needs another magic number), unstable across browsers, and breaks the moment a designer reships the source asset with different padding.

CLI Reference

normalize_logo.py

Flag Default Notes
--src required input image (PNG/JPG/rasterized SVG)
--dst required output PNG path; parent dirs auto-created
--height 80 target content height. Use 240 for retina poster export (scale: 2) — 80 looks soft after 2× downscale.
--invert auto auto / off / full / selective (selective preserves colored icons)

add_partner.py

Flag Notes
--repo website repo root; defaults to SKILL_MAINTAIN_PARTNERS_SITE_ROOT, profile JSON, or legacy SKILL_WEB_ROOT / PARTNERS_SITE_ROOT
--partners-file PARTNERS TSX file; defaults to SKILL_MAINTAIN_PARTNERS_FILE, profile JSON, legacy SKILL_PARTNERS_FILE / PARTNERS_FILE, PartnersGrid.tsx, or WorkshopDispatch.tsx
--name display name (CJK ok)
--href brand URL
--logo path under /public, e.g. /partners/foo/logo.png
--key i18n key, e.g. partnerFooTagline
--category compute / peer / invest / media / community; default community
--zh / --en / --ja / --th tagline strings (all required)
--show-name render name next to icon for narrow logos

audit_partners.py

Flag Notes
--repo website repo root; defaults to SKILL_MAINTAIN_PARTNERS_SITE_ROOT, profile JSON, or legacy SKILL_WEB_ROOT / PARTNERS_SITE_ROOT
--partners-file PARTNERS TSX file; defaults to SKILL_MAINTAIN_PARTNERS_FILE, profile JSON, legacy SKILL_PARTNERS_FILE / PARTNERS_FILE, PartnersGrid.tsx, or WorkshopDispatch.tsx
--probe HTTP-probe every href (slow, needs proxy env vars)

Network proxy

Sandbox child processes don't inherit the system ClashX proxy. Before fetching logos with lov-find-logo or probing partner URLs, export:

export https_proxy=http://127.0.0.1:7890 \
       http_proxy=http://127.0.0.1:7890 \
       all_proxy=socks5://127.0.0.1:7891

audit_partners.py already injects these for curl invocations.

Dependencies

git clone https://example.com/skills/find-logo-skill \
  "${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}/lov-find-logo"
python3 -m pip install Pillow
brew install librsvg  # for SVG logo sources

Runtime context (shared)

运行前读取本 Skill 包的 skill.yaml,由宿主提供 skill-runtime/v1 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。

  • 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
  • required: true 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。
  • 报错提供可复制的 context_id、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。

通用反馈闭环

用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:

  1. 先判断意见是 task-specific(仅本次)还是 reusable(可跨任务复用)。
  2. task-specific 只修改当前任务,不改 Skill。
  3. reusable 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
  4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
  5. reusable 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
  • references
    • user-config.md 2 KB
      # User Configuration
      
      This skill can maintain any site that follows the same partner-strip shape as
      the Skill Publisher website. It should not require Mark's local path.
      
      ## Website Root
      
      Resolve the website repo root in this order:
      
      1. `--repo <path>` on `add_partner.py` or `audit_partners.py`.
      2. `SKILL_MAINTAIN_PARTNERS_SITE_ROOT`.
      3. Shared profile JSON at
         `${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json}`.
      4. Fallback to `$HOME/skill-publisher/coding/web` only if that repo exists.
      
      `SKILL_WEB_ROOT` and `PARTNERS_SITE_ROOT` are accepted as legacy aliases.
      
      Resolve the partners TSX file in this order:
      
      1. `--partners-file <path>` on `add_partner.py` or `audit_partners.py`.
      2. `SKILL_MAINTAIN_PARTNERS_FILE`.
      3. Shared profile keys `sites.partners_file`, `skill-publisher.partners_file`,
         `partners.file`, or `workspace.partners_file`.
      4. `app/(main)/(home)/PartnersGrid.tsx`, then legacy
         `app/(main)/(home)/WorkshopDispatch.tsx`.
      
      `SKILL_PARTNERS_FILE` and `PARTNERS_FILE` are accepted as legacy aliases.
      
      Supported profile keys:
      
      ```json
      {
        "sites": {
          "skill-publisher_web": "$HOME/skill-publisher/coding/web",
          "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
        },
        "skill-publisher": {
          "web_root": "$HOME/skill-publisher/coding/web",
          "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
        },
        "workspace": {
          "web_root": "$HOME/skill-publisher/coding/web",
          "website_root": "$HOME/skill-publisher/coding/web",
          "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
        }
      }
      ```
      
      ## Required Site Shape
      
      ```text
      <web-root>/
      ├── app/(main)/(home)/PartnersGrid.tsx       # PARTNERS array
      ├── public/partners/<slug>/logo.png          # logo files
      └── src/i18n/messages/
          ├── zh-CN.json
          ├── en.json
          ├── ja.json
          └── th.json
      ```
      
      If a user's site uses different file names or locale paths, do not edit blindly.
      Ask for the equivalent paths and add explicit CLI flags before proceeding.
      
  • scripts
    • add_partner.py 8.5 KB
      #!/usr/bin/env python3
      """Append a partner to the configured PARTNERS TSX file + add tagline keys to all 4 locale JSONs.
      
      Idempotent: if the partner name or taglineKey already exists, exits with an error
      rather than duplicating. Logo file must already exist (use normalize_logo.py first).
      """
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import sys
      from pathlib import Path
      
      
      LOCALES = ["zh-CN", "en", "ja", "th"]
      
      
      def is_website_repo(repo: Path) -> bool:
          return (
              (repo / "app/(main)/(home)/PartnersGrid.tsx").exists()
              or (repo / "app/(main)/(home)/WorkshopDispatch.tsx").exists()
              or ((repo / "app").is_dir() and (repo / "public").is_dir())
          )
      
      
      def _nested(data: dict, dotted: str) -> str | None:
          cur = data
          for part in dotted.split("."):
              if not isinstance(cur, dict) or part not in cur:
                  return None
              cur = cur[part]
          return str(cur) if cur else None
      
      
      def _expand_path(value: str) -> Path:
          return Path(os.path.expandvars(value)).expanduser()
      
      
      def resolve_repo(cli_repo: str | None) -> Path:
          candidates: list[str] = []
          if cli_repo:
              repo = _expand_path(cli_repo)
              if is_website_repo(repo):
                  return repo
              sys.exit(f"Website repo not found at --repo: {repo}")
          for env_key in (
              "SKILL_MAINTAIN_PARTNERS_SITE_ROOT",
              "SKILL_WEB_ROOT",
              "PARTNERS_SITE_ROOT",
          ):
              if os.environ.get(env_key):
                  candidates.append(os.environ[env_key])
      
          profile = Path(
              os.environ.get("SKILL_PROFILE_PATH")
              or os.environ.get("AGENT_SKILL_PROFILE")
              or os.environ.get("SKILL_SKILL_PROFILE")
              or str(Path.home() / ".skill-publisher/skills/profile.json")
          ).expanduser()
          if profile.exists():
              try:
                  data = json.loads(profile.read_text())
              except json.JSONDecodeError as exc:
                  sys.exit(f"Invalid JSON in {profile}: {exc}")
              for key in (
                  "sites.skill-publisher_web",
                  "skill-publisher.web_root",
                  "workspace.web_root",
                  "workspace.website_root",
              ):
                  value = _nested(data, key)
                  if value:
                      candidates.append(value)
      
          for candidate in candidates:
              repo = _expand_path(candidate)
              if is_website_repo(repo):
                  return repo
      
          sys.exit(
              "Website repo not found. Pass --repo, set SKILL_MAINTAIN_PARTNERS_SITE_ROOT, or add "
              "sites.skill-publisher_web / skill-publisher.web_root / workspace.web_root to "
              f"{profile}. SKILL_WEB_ROOT and PARTNERS_SITE_ROOT are still accepted as legacy aliases."
          )
      
      
      def resolve_partners_file(repo: Path, cli_partners_file: str | None) -> Path:
          def as_path(value: str) -> Path:
              p = _expand_path(value)
              return p if p.is_absolute() else repo / p
      
          if cli_partners_file:
              path = as_path(cli_partners_file)
              if path.exists():
                  return path
              sys.exit(f"Partners file not found at --partners-file: {path}")
      
          candidates: list[str] = []
          for env_key in (
              "SKILL_MAINTAIN_PARTNERS_FILE",
              "SKILL_PARTNERS_FILE",
              "PARTNERS_FILE",
          ):
              if os.environ.get(env_key):
                  candidates.append(os.environ[env_key])
      
          profile = Path(
              os.environ.get("SKILL_PROFILE_PATH")
              or os.environ.get("AGENT_SKILL_PROFILE")
              or os.environ.get("SKILL_SKILL_PROFILE")
              or str(Path.home() / ".skill-publisher/skills/profile.json")
          ).expanduser()
          if profile.exists():
              try:
                  data = json.loads(profile.read_text())
              except json.JSONDecodeError as exc:
                  sys.exit(f"Invalid JSON in {profile}: {exc}")
              for key in (
                  "sites.partners_file",
                  "skill-publisher.partners_file",
                  "partners.file",
                  "workspace.partners_file",
              ):
                  value = _nested(data, key)
                  if value:
                      candidates.append(value)
      
          candidates.extend(
              [
                  "app/(main)/(home)/PartnersGrid.tsx",
                  "app/(main)/(home)/WorkshopDispatch.tsx",
              ]
          )
      
          for candidate in candidates:
              path = as_path(candidate)
              if path.exists() and "PARTNERS" in path.read_text():
                  return path
      
          sys.exit(
              "Partners file not found. Pass --partners-file, set SKILL_MAINTAIN_PARTNERS_FILE, "
              "or add sites.partners_file / skill-publisher.partners_file to the shared profile. "
              "SKILL_PARTNERS_FILE and PARTNERS_FILE are still accepted as legacy aliases."
          )
      
      
      def insert_partner_entry(
          src: str,
          name: str,
          href: str,
          logo: str,
          key: str,
          category: str,
          show_name: bool,
      ) -> str:
          if f'name: "{name}"' in src:
              sys.exit(f"Partner '{name}' already exists in PARTNERS")
      
          show_suffix = ", showName: true" if show_name else ""
          line = (
              f'  {{ name: "{name}", '
              f'href: "{href}", '
              f'logo: "{logo}", '
              f'taglineKey: "{key}", '
              f'category: "{category}"{show_suffix} }},\n'
          )
      
          pattern = re.compile(r"(export const PARTNERS:\s*Partner\[\]\s*=\s*\[[\s\S]*?)(\n\]\n)", re.M)
          new_src, n = pattern.subn(lambda m: f"{m.group(1)}\n{line}{m.group(2)}", src, count=1)
          if n == 0:
              sys.exit("Could not find PARTNERS array closing bracket — manual edit required")
          return new_src
      
      
      def insert_tagline(locale_path: Path, key: str, value: str):
          text = locale_path.read_text()
          if f'"{key}"' in text:
              return  # idempotent
          # Insert as the new last entry inside the dispatch block (before its closing "}").
          # Strategy: find the last `partner...Tagline` entry and append after it.
          matches = list(re.finditer(r'(\n\s*"partner\w+Tagline":\s*"[^"]*")(,?)\n', text))
          if not matches:
              sys.exit(f"Could not find any partner*Tagline entries in {locale_path.name}")
          last = matches[-1]
          insertion = f'{last.group(1)},\n    "{key}": "{value}"\n'
          new_text = text[: last.start()] + insertion + text[last.end():]
          locale_path.write_text(new_text)
      
      
      def main():
          ap = argparse.ArgumentParser(description=__doc__)
          ap.add_argument(
              "--repo",
              default=None,
              help="Website repo root. Defaults to SKILL_MAINTAIN_PARTNERS_SITE_ROOT, profile JSON, or legacy SKILL_WEB_ROOT/PARTNERS_SITE_ROOT.",
          )
          ap.add_argument(
              "--partners-file",
              default=None,
              help="Partners TSX file. Defaults to SKILL_MAINTAIN_PARTNERS_FILE, profile JSON, legacy SKILL_PARTNERS_FILE/PARTNERS_FILE, PartnersGrid.tsx, or WorkshopDispatch.tsx.",
          )
          ap.add_argument("--name", required=True, help="Display name (CJK ok)")
          ap.add_argument("--href", required=True, help="Brand homepage URL")
          ap.add_argument(
              "--logo",
              required=True,
              help="Logo path under /public, e.g. /partners/foo/logo.png",
          )
          ap.add_argument(
              "--key",
              required=True,
              help="Tagline i18n key, e.g. partnerFooTagline",
          )
          ap.add_argument(
              "--category",
              choices=["compute", "peer", "invest", "media", "community"],
              default="community",
              help="Partner category used by the Skill Publisher PartnersGrid component",
          )
          ap.add_argument("--zh", required=True, help="Tagline in Simplified Chinese")
          ap.add_argument("--en", required=True, help="Tagline in English")
          ap.add_argument("--ja", required=True, help="Tagline in Japanese")
          ap.add_argument("--th", required=True, help="Tagline in Thai")
          ap.add_argument(
              "--show-name",
              action="store_true",
              help="Render the name next to the logo (use for icon-only logos < 32px wide)",
          )
          args = ap.parse_args()
      
          repo = resolve_repo(args.repo)
          partners_file = resolve_partners_file(repo, args.partners_file)
          public = repo / "public"
          locales_dir = repo / "src/i18n/messages"
      
          logo_file = public / args.logo.lstrip("/")
          if not logo_file.exists():
              sys.exit(f"Logo file missing: {logo_file}\nRun normalize_logo.py first.")
      
          src = partners_file.read_text()
          new_src = insert_partner_entry(
              src,
              args.name,
              args.href,
              args.logo,
              args.key,
              args.category,
              args.show_name,
          )
          partners_file.write_text(new_src)
          print(f"✓ Added '{args.name}' to PARTNERS")
      
          taglines = {"zh-CN": args.zh, "en": args.en, "ja": args.ja, "th": args.th}
          for loc, txt in taglines.items():
              insert_tagline(locales_dir / f"{loc}.json", args.key, txt)
              print(f"✓ Added {args.key} to {loc}.json")
      
      
      if __name__ == "__main__":
          main()
      
    • audit_partners.py 7.6 KB
      #!/usr/bin/env python3
      """Audit the partners section: dead URLs, missing logos, missing i18n keys.
      
      Walks PARTNERS in the configured partners TSX file, then for each entry verifies:
        1. logo file exists at the referenced public path
        2. taglineKey exists in all 4 locale JSONs (zh-CN, en, ja, th)
        3. href returns a non-error HTTP status (skipped unless --probe)
      """
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import subprocess
      import sys
      from pathlib import Path
      
      
      LOCALES = ["zh-CN", "en", "ja", "th"]
      
      PROXY_ENV = {
          "https_proxy": "http://127.0.0.1:7890",
          "http_proxy": "http://127.0.0.1:7890",
          "all_proxy": "socks5://127.0.0.1:7891",
      }
      
      PARTNER_RE = re.compile(
          r'\{\s*name:\s*"([^"]+)"\s*,\s*href:\s*"([^"]+)"\s*,\s*logo:\s*"([^"]+)"\s*,\s*taglineKey:\s*"([^"]+)"',
          re.M,
      )
      
      
      def is_website_repo(repo: Path) -> bool:
          return (
              (repo / "app/(main)/(home)/PartnersGrid.tsx").exists()
              or (repo / "app/(main)/(home)/WorkshopDispatch.tsx").exists()
              or ((repo / "app").is_dir() and (repo / "public").is_dir())
          )
      
      
      def _nested(data: dict, dotted: str) -> str | None:
          cur = data
          for part in dotted.split("."):
              if not isinstance(cur, dict) or part not in cur:
                  return None
              cur = cur[part]
          return str(cur) if cur else None
      
      
      def _expand_path(value: str) -> Path:
          return Path(os.path.expandvars(value)).expanduser()
      
      
      def resolve_repo(cli_repo: str | None) -> Path:
          candidates: list[str] = []
          if cli_repo:
              repo = _expand_path(cli_repo)
              if is_website_repo(repo):
                  return repo
              sys.exit(f"Website repo not found at --repo: {repo}")
          for env_key in (
              "SKILL_MAINTAIN_PARTNERS_SITE_ROOT",
              "SKILL_WEB_ROOT",
              "PARTNERS_SITE_ROOT",
          ):
              if os.environ.get(env_key):
                  candidates.append(os.environ[env_key])
      
          profile = Path(
              os.environ.get("SKILL_PROFILE_PATH")
              or os.environ.get("AGENT_SKILL_PROFILE")
              or os.environ.get("SKILL_SKILL_PROFILE")
              or str(Path.home() / ".skill-publisher/skills/profile.json")
          ).expanduser()
          if profile.exists():
              try:
                  data = json.loads(profile.read_text())
              except json.JSONDecodeError as exc:
                  sys.exit(f"Invalid JSON in {profile}: {exc}")
              for key in (
                  "sites.skill-publisher_web",
                  "skill-publisher.web_root",
                  "workspace.web_root",
                  "workspace.website_root",
              ):
                  value = _nested(data, key)
                  if value:
                      candidates.append(value)
      
          for candidate in candidates:
              repo = _expand_path(candidate)
              if is_website_repo(repo):
                  return repo
      
          sys.exit(
              "Website repo not found. Pass --repo, set SKILL_MAINTAIN_PARTNERS_SITE_ROOT, or add "
              "sites.skill-publisher_web / skill-publisher.web_root / workspace.web_root to "
              f"{profile}. SKILL_WEB_ROOT and PARTNERS_SITE_ROOT are still accepted as legacy aliases."
          )
      
      
      def resolve_partners_file(repo: Path, cli_partners_file: str | None) -> Path:
          def as_path(value: str) -> Path:
              p = _expand_path(value)
              return p if p.is_absolute() else repo / p
      
          if cli_partners_file:
              path = as_path(cli_partners_file)
              if path.exists():
                  return path
              sys.exit(f"Partners file not found at --partners-file: {path}")
      
          candidates: list[str] = []
          for env_key in (
              "SKILL_MAINTAIN_PARTNERS_FILE",
              "SKILL_PARTNERS_FILE",
              "PARTNERS_FILE",
          ):
              if os.environ.get(env_key):
                  candidates.append(os.environ[env_key])
      
          profile = Path(
              os.environ.get("SKILL_PROFILE_PATH")
              or os.environ.get("AGENT_SKILL_PROFILE")
              or os.environ.get("SKILL_SKILL_PROFILE")
              or str(Path.home() / ".skill-publisher/skills/profile.json")
          ).expanduser()
          if profile.exists():
              try:
                  data = json.loads(profile.read_text())
              except json.JSONDecodeError as exc:
                  sys.exit(f"Invalid JSON in {profile}: {exc}")
              for key in (
                  "sites.partners_file",
                  "skill-publisher.partners_file",
                  "partners.file",
                  "workspace.partners_file",
              ):
                  value = _nested(data, key)
                  if value:
                      candidates.append(value)
      
          candidates.extend(
              [
                  "app/(main)/(home)/PartnersGrid.tsx",
                  "app/(main)/(home)/WorkshopDispatch.tsx",
              ]
          )
      
          for candidate in candidates:
              path = as_path(candidate)
              if path.exists() and "PARTNERS" in path.read_text():
                  return path
      
          sys.exit(
              "Partners file not found. Pass --partners-file, set SKILL_MAINTAIN_PARTNERS_FILE, "
              "or add sites.partners_file / skill-publisher.partners_file to the shared profile. "
              "SKILL_PARTNERS_FILE and PARTNERS_FILE are still accepted as legacy aliases."
          )
      
      
      def parse_partners(src: str) -> list[dict]:
          return [
              {"name": m[0], "href": m[1], "logo": m[2], "taglineKey": m[3]}
              for m in PARTNER_RE.findall(src)
          ]
      
      
      def load_tagline_keys(repo: Path, locale: str) -> set[str]:
          p = repo / "src/i18n/messages" / f"{locale}.json"
          data = json.loads(p.read_text())
          # Tagline keys live under dispatch.partner*Tagline
          dispatch = data.get("dispatch", {})
          return {k for k in dispatch if k.startswith("partner") and k.endswith("Tagline")}
      
      
      def probe(url: str, timeout: int = 8) -> int:
          cmd = ["curl", "-sL", "-m", str(timeout), "-o", "/dev/null", "-w", "%{http_code}", url]
          r = subprocess.run(cmd, env={**__import__("os").environ, **PROXY_ENV}, capture_output=True)
          try:
              return int(r.stdout.decode().strip() or 0)
          except ValueError:
              return 0
      
      
      def main():
          ap = argparse.ArgumentParser(description=__doc__)
          ap.add_argument(
              "--repo",
              default=None,
              help="Website repo root. Defaults to SKILL_MAINTAIN_PARTNERS_SITE_ROOT, profile JSON, or legacy SKILL_WEB_ROOT/PARTNERS_SITE_ROOT.",
          )
          ap.add_argument(
              "--partners-file",
              default=None,
              help="Partners TSX file. Defaults to SKILL_MAINTAIN_PARTNERS_FILE, profile JSON, legacy SKILL_PARTNERS_FILE/PARTNERS_FILE, PartnersGrid.tsx, or WorkshopDispatch.tsx.",
          )
          ap.add_argument("--probe", action="store_true", help="HTTP-probe each href (slow)")
          args = ap.parse_args()
      
          repo = resolve_repo(args.repo)
          partners_file = resolve_partners_file(repo, args.partners_file)
          public = repo / "public"
      
          if not partners_file.exists():
              sys.exit(f"Not found: {partners_file}")
      
          partners = parse_partners(partners_file.read_text())
          if not partners:
              sys.exit("PARTNERS array empty or unparseable")
      
          print(f"Parsed {len(partners)} partners")
      
          locale_keys = {loc: load_tagline_keys(repo, loc) for loc in LOCALES}
      
          issues: list[str] = []
          for p in partners:
              logo_path = public / p["logo"].lstrip("/")
              if not logo_path.exists():
                  issues.append(f"  [missing-logo] {p['name']}: {logo_path}")
      
              for loc in LOCALES:
                  if p["taglineKey"] not in locale_keys[loc]:
                      issues.append(f"  [missing-i18n:{loc}] {p['name']}: {p['taglineKey']}")
      
              if args.probe:
                  code = probe(p["href"])
                  if code == 0 or code >= 400:
                      issues.append(f"  [dead-url:{code}] {p['name']}: {p['href']}")
      
          if issues:
              print(f"\n{len(issues)} issue(s):")
              print("\n".join(issues))
              sys.exit(1)
          print("\n✓ All partners healthy")
      
      
      if __name__ == "__main__":
          main()
      
    • normalize_logo.py 4 KB
      #!/usr/bin/env python3
      """Trim transparent edges and resize a logo to a fixed canvas height.
      
      Standard for the Skill Publisher partners strip: 80px tall, width auto, optimized PNG.
      Optionally invert white-on-transparent logos to black so they remain visible
      against the light grayscale strip.
      """
      from __future__ import annotations
      
      import argparse
      import os
      import sys
      from pathlib import Path
      
      try:
          from PIL import Image, ImageOps
      except ImportError:
          sys.exit("Missing dependency: pip install Pillow --break-system-packages")
      
      
      def detect_white_on_transparent(img: "Image.Image", threshold: int = 200) -> bool:
          """True if the average opaque pixel is near-white (logo invisible on white bg)."""
          px = img.load()
          rs, gs, bs, n = 0, 0, 0, 0
          step_y = max(1, img.height // 50)
          step_x = max(1, img.width // 50)
          for y in range(0, img.height, step_y):
              for x in range(0, img.width, step_x):
                  r, g, b, a = px[x, y]
                  if a > 128:
                      rs += r
                      gs += g
                      bs += b
                      n += 1
          if not n:
              return False
          return (rs // n) > threshold and (gs // n) > threshold and (bs // n) > threshold
      
      
      def whiten_to_transparent(img: "Image.Image", threshold: int = 245) -> "Image.Image":
          """JPEG inputs have white backgrounds; convert near-white to transparent for tight crop."""
          px = img.load()
          for y in range(img.height):
              for x in range(img.width):
                  r, g, b, a = px[x, y]
                  if r > threshold and g > threshold and b > threshold:
                      px[x, y] = (255, 255, 255, 0)
          return img
      
      
      def invert_rgb_keep_alpha(img: "Image.Image") -> "Image.Image":
          """Invert color channels but preserve alpha — turns white-on-transparent into black-on-transparent."""
          r, g, b, a = img.split()
          inv = ImageOps.invert(Image.merge("RGB", (r, g, b)))
          ir, ig, ib = inv.split()
          return Image.merge("RGBA", (ir, ig, ib, a))
      
      
      def selective_white_to_black(img: "Image.Image") -> "Image.Image":
          """Turn only near-white pixels black; preserve colored content (e.g. blue icon, white wordmark)."""
          px = img.load()
          for y in range(img.height):
              for x in range(img.width):
                  r, g, b, a = px[x, y]
                  if a > 0 and r > 200 and g > 200 and b > 200:
                      px[x, y] = (0, 0, 0, a)
          return img
      
      
      def normalize(src: Path, dst: Path, height: int, invert_mode: str) -> tuple[int, int]:
          img = Image.open(src).convert("RGBA")
      
          if src.suffix.lower() in (".jpg", ".jpeg"):
              img = whiten_to_transparent(img)
      
          if invert_mode == "auto":
              if detect_white_on_transparent(img):
                  invert_mode = "full"
              else:
                  invert_mode = "off"
      
          if invert_mode == "full":
              img = invert_rgb_keep_alpha(img)
          elif invert_mode == "selective":
              img = selective_white_to_black(img)
      
          bbox = img.getbbox()
          if bbox:
              img = img.crop(bbox)
      
          w, h = img.size
          new_w = int(w * height / h)
          img = img.resize((new_w, height), Image.LANCZOS)
      
          dst.parent.mkdir(parents=True, exist_ok=True)
          img.save(dst, optimize=True)
          return new_w, height
      
      
      def main():
          ap = argparse.ArgumentParser(description=__doc__)
          ap.add_argument("--src", required=True, help="Source image path (PNG/JPG/SVG-rasterized)")
          ap.add_argument("--dst", required=True, help="Output PNG path")
          ap.add_argument("--height", type=int, default=80, help="Target content height in pixels (default: 80)")
          ap.add_argument(
              "--invert",
              choices=["auto", "off", "full", "selective"],
              default="auto",
              help="auto: detect white-on-transparent and full-invert; "
              "selective: only flip near-white pixels to black (preserves colored icons); "
              "off: no inversion",
          )
          args = ap.parse_args()
      
          src = Path(args.src).expanduser()
          dst = Path(args.dst).expanduser()
          if not src.exists():
              sys.exit(f"src not found: {src}")
      
          w, h = normalize(src, dst, args.height, args.invert)
          print(f"{dst}: {w}x{h}")
      
      
      if __name__ == "__main__":
          main()
      
  • .gitignore 78 B · in bundle
  • CHANGELOG.md 3.5 KB
    # Changelog
    
    All notable changes to this skill are documented here.
    Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/)
    
    ## [0.10.0] - 2026-08-24
    
    ### Added
    
    - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill
    
    ## [0.9.1] - 2026-05-07
    
    ### Fixed
    
    - make dependency install paths configurable
    - replace fixed runtime skill paths with SKILL_SKILLS_INSTALL_DIR
    - use python3 -m pip install Pillow in dependency examples
    
    ## [0.9.0] - 2026-05-07
    
    ### Added
    
    - require explicit partner website config
    - remove implicit `$HOME/skill-publisher/coding/web` fallback in favor of CLI, `SKILL_*` env, or shared profile
    - expand `$HOME`-style variables from shared profile paths
    
    ## [0.8.0] - 2026-05-07
    
    ### Added
    
    - standardize partner env vars on SKILL_MAINTAIN_PARTNERS namespace
    - use SKILL_MAINTAIN_PARTNERS_SITE_ROOT and SKILL_MAINTAIN_PARTNERS_FILE as primary variables
    - keep older PARTNERS_* and SKILL_WEB_ROOT aliases for migration only
    
    ## [0.7.0] - 2026-05-07
    
    ### Added
    
    - move default profile lookup under ~/.skill-publisher
    - keep AGENT_SKILL_PROFILE as the portable override
    - keep PARTNERS_SITE_ROOT and PARTNERS_FILE as neutral primary runtime variables
    
    ## [0.6.0] - 2026-05-07
    
    ### Added
    
    - switch partner config API to neutral env names
    - use PARTNERS_SITE_ROOT and PARTNERS_FILE as primary variables
    - retain SKILL_WEB_ROOT and SKILL_PARTNERS_FILE as legacy aliases
    
    ## [0.5.0] - 2026-05-06
    
    ### Added
    
    - add configurable website repo resolution
    - support --repo and shared profile JSON
    - document portable partner-site initialization
    - support configurable partners TSX files, including the current PartnersGrid.tsx location
    - add partner category handling for the Skill Publisher PartnersGrid schema
    
    ## [0.4.0] - 2026-05-06
    
    ### Changed
    
    - 声明 `depends_on: [lov-find-logo]`,把 logo 发现统一交给 find-logo skill。
    - Op 1 改为读取 `~/.skill-publisher/logo-collection/<slug>/logo.<ext>`,不再维护独立 homepage scraper。
    - README 安装说明补充 `lov-find-logo`,移除 Playwright/SPA scraping fallback。
    
    ### Removed
    
    - 删除 `scripts/scrape_logo.py`,避免 maintain-partners 和 find-logo 之间重复维护抓取逻辑。
    
    ## [0.3.0] - 2026-04-27
    
    ### Added
    
    - Op 5 升级到 retina-ready 工作流:240px 默认 + fixed-box 矩阵 + icon+wordmark 合成
    - Op 5 默认 raster 高度从 80 升到 240(3× retina export 密度),强调必须从原图 normalize
    - 新增 Op 5 step 3:sed 去除 SVG 内嵌背景 rect,避免 filter 翻成白块盖住图标
    - 新增 Op 5 step 4:96×30 fixed-box 矩阵(含边框/圆角)替代 auto-width flex,宽度可预测
    - 新增 Op 5 step 6:图标-only SVG → 用 PIL + 品牌字体合成 icon+wordmark PNG
    - frontmatter description 扩充:添加 logo 不清晰 / 矩阵格子 / 等宽 box / 图标加文字 等触发短语
    
    ## [0.2.0] - 2026-04-27
    
    ### Added
    
    - 新增多 logo 等高对齐工作流(Op 5):file-level 裁切 + 等高 wrap box + 深底统一反白滤镜
    - SVG 源文件需先用 rsvg-convert 栅格化再 normalize,否则 viewBox padding 无法裁切
    - 深底反白配方:filter: brightness(0) invert(1) opacity(0.88);已是白色源文件用 .ps-logo-original 跳过反白
    - 明确反模式:禁止用 per-logo magic-number CSS height 调整(不稳定、不可维护)
    - frontmatter description 扩充触发短语:logo 不一样高 / logo 对齐 / logo 大小不一致 / logo 颜色不统一
    
  • README.md 4.5 KB
    # 伙伴名录 · Partner Directory
    
    ![Version](https://img.shields.io/badge/version-0.10.0-CC785C)
    
    Maintain the Skill Publisher website's "Trusted By" partners section: collect brand
    logos through `lov-find-logo`, normalize to the 80px canvas, append
    entries with i18n taglines across 4 locales, and audit for dead URLs /
    missing assets.
    
    Part of [skills](https://example.com/skills/skills) — by [example.com](https://example.com)
    
    ## Install
    
    ```bash
    SKILLS_DIR="${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}"
    git clone https://example.com/skills/maintain-partners-skill "$SKILLS_DIR/lov-maintain-partners"
    git clone https://example.com/skills/find-logo-skill "$SKILLS_DIR/lov-find-logo"
    python3 -m pip install Pillow
    brew install librsvg  # for SVG logo sources
    ```
    
    ## Configuration
    
    Set the website repo root with `--repo`, `SKILL_MAINTAIN_PARTNERS_SITE_ROOT`, or the shared
    profile at `${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json}`.
    `SKILL_WEB_ROOT` and `PARTNERS_SITE_ROOT` remain accepted as legacy aliases.
    
    Set the PARTNERS TSX file with `--partners-file`, `SKILL_MAINTAIN_PARTNERS_FILE`, or profile
    keys. The default checks `app/(main)/(home)/PartnersGrid.tsx` first, then
    legacy `app/(main)/(home)/WorkshopDispatch.tsx`.
    `SKILL_PARTNERS_FILE` and `PARTNERS_FILE` remain accepted as legacy aliases.
    
    Supported profile keys:
    
    ```json
    {
      "sites": {
        "skill-publisher_web": "$HOME/projects/my-site",
        "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
      },
      "skill-publisher": {
        "web_root": "$HOME/projects/my-site",
        "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
      },
      "workspace": {
        "web_root": "$HOME/projects/my-site",
        "partners_file": "app/(main)/(home)/PartnersGrid.tsx"
      }
    }
    ```
    
    ## What it does
    
    The Skill Publisher homepage runs a "Trusted By" strip that renders 30+ partner
    logos against a `grayscale opacity-60` filter. Maintaining it means three
    recurring tasks:
    
    1. **Collecting** — invoking `lov-find-logo` to pull and archive brand logos.
    2. **Normalizing** — every logo must trim to its content bbox and resize to
       exactly 80px tall so the strip looks even. White-on-transparent logos must
       be inverted so they show on the light background.
    3. **Wiring** — appending the partner to `PARTNERS` in the configured partners
       TSX file and adding a `partner*Tagline` key to all 4 locale JSONs
       (zh-CN / en / ja / th).
    
    Logo discovery is delegated to `lov-find-logo`; this skill does not keep
    its own homepage crawler or fallback scraper.
    
    This skill is three single-file Python CLIs plus an AI workflow that orchestrates them.
    
    ## Scripts
    
    ```text
    normalize_logo.py   Trim, optional inversion, resize to 80px, write PNG
    add_partner.py      Append to PARTNERS array + i18n JSONs (idempotent)
    audit_partners.py   Walk PARTNERS; report missing logos / i18n keys / dead URLs
    ```
    
    ## Quick examples
    
    ```bash
    # Collect a logo through the required find-logo skill
    SKILL_ROOT="${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}"
    WEB_ROOT="${SKILL_MAINTAIN_PARTNERS_SITE_ROOT:?Set this or pass --repo}"
    PARTNERS_TSX="${SKILL_MAINTAIN_PARTNERS_FILE:-app/(main)/(home)/PartnersGrid.tsx}"
    
    python3 "$SKILL_ROOT/lov-find-logo/scripts/find_logo.py" \
      --name "Example" --url https://example.com --slug example --json
    
    # Normalize: auto-invert white-on-transparent
    normalize_logo.py --src ~/.skill-publisher/logo-collection/example/logo.png \
                      --dst "$WEB_ROOT/public/partners/example/logo.png"
    
    # Add to PARTNERS + i18n
    add_partner.py --repo "$WEB_ROOT" \
                   --partners-file "$PARTNERS_TSX" \
                   --name "Example" --href "https://example.com" \
                   --logo "/partners/example/logo.png" \
                   --key partnerExampleTagline \
                   --category community \
                   --zh "Example · 一句话定位" \
                   --en "Example · one-line positioning" \
                   --ja "Example · 一行紹介" \
                   --th "Example · บรรยายหนึ่งบรรทัด"
    
    # Audit (use --probe to HTTP-check every URL)
    audit_partners.py --repo "$WEB_ROOT" --partners-file "$PARTNERS_TSX" --probe
    ```
    
    ## Repo layout assumed
    
    ```
    <web-root>/
    ├── app/(main)/(home)/PartnersGrid.tsx       ← PARTNERS array
    ├── public/partners/<slug>/logo.png          ← logo files
    └── src/i18n/messages/
        ├── zh-CN.json    ← dispatch.partner*Tagline
        ├── en.json
        ├── ja.json
        └── th.json
    ```
    
    If you fork this for another site, edit the constants at the top of each script.
    
    ## License
    
    MIT
    
  • SKILL.md 16.6 KB
    ---
    name: lov-maintain-partners
    description: >
      Maintain the Skill Publisher website's partners section AND align partner logo
      rows on event posters / hero strips: reuse lov-find-logo for brand
      logo discovery, normalize collected logos to a 240px-tall content canvas
      (retina-ready), rasterize SVGs via rsvg-convert before normalizing (so SVG
      viewBox padding gets cropped),
      strip embedded background rects from icon-style SVGs, composite icon +
      wordmark when only an icon is available (using brand fonts), wrap logos
      in a fixed-size grid box (96×30 with subtle border) for stable matrix
      layouts, replace existing logos with user-provided files, append new
      partners to the PARTNERS array with i18n taglines across zh-CN/en/ja/th,
      and audit the section for dead URLs / missing files / missing translations.
      Also handles cross-asset visual height parity (multi-logo strips on dark
      backgrounds, "logo 不等高", unified-color filter recipe). Trigger when the
      user mentions "合作伙伴", "partners", "trusted by", "新增 logo", "标准化 logo",
      "替换 logo", "审计合作伙伴", "维护合作伙伴", "logo 不一样高", "logo 对齐",
      "logo 大小不一致", "logo 颜色不统一", "logo 不清晰", "logo 糊了", "矩阵格子",
      "等宽 box", "图标加文字", "compose wordmark".
    license: MIT
    compatibility: >
      Requires the lov-find-logo skill plus Python 3.8+ with Pillow
      (`pip install Pillow --break-system-packages`). Requires rsvg-convert
      (`brew install librsvg`) when the selected logo source is SVG.
      Tested on macOS; Linux should work. Website repo paths are configurable via
      --repo, SKILL_MAINTAIN_PARTNERS_SITE_ROOT, or the shared user profile; this skill must not
      require Mark's personal absolute path. Legacy path aliases remain accepted
      for existing local setups.
    depends_on:
      - lov-find-logo
    metadata:
      author: contributors
      version: "0.10.0"
      tags: [skill-publisher, web, branding, i18n]
    ---
    
    # 伙伴名录 · Partner Directory
    
    Maintains the configured website repo. Resolve the path from `--repo`,
    `SKILL_MAINTAIN_PARTNERS_SITE_ROOT`, or the shared user profile. The partners
    strip usually lives in `app/(main)/(home)/PartnersGrid.tsx` as a `PARTNERS:
    Partner[]` array; older sites may still keep it in
    `app/(main)/(home)/WorkshopDispatch.tsx`. Logos live in
    `public/partners/<slug>/logo.png`; taglines in
    `src/i18n/messages/{zh-CN,en,ja,th}.json` under `dispatch.partner*Tagline`.
    
    ## User Configuration
    
    Before touching files, resolve:
    
    ```bash
    SKILL_ROOT="${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}"
    SKILL_DIR="${SKILL_DIR:-$SKILL_ROOT/lov-maintain-partners}"
    WEB_ROOT="${SKILL_MAINTAIN_PARTNERS_SITE_ROOT:?Set this or pass --repo}"
    PARTNERS_TSX="${SKILL_MAINTAIN_PARTNERS_FILE:-app/(main)/(home)/PartnersGrid.tsx}"
    ```
    
    Use this precedence for the website root:
    
    1. Explicit `--repo <path>` on `add_partner.py` / `audit_partners.py`.
    2. `SKILL_MAINTAIN_PARTNERS_SITE_ROOT`.
    3. Shared profile JSON at
       `${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json}`.
    
    `SKILL_WEB_ROOT` and `PARTNERS_SITE_ROOT` are accepted as legacy aliases,
    but should not be the public contract for reusable skills.
    
    Use this precedence for the partners TSX file:
    
    1. Explicit `--partners-file <path>`.
    2. `SKILL_MAINTAIN_PARTNERS_FILE`.
    3. Shared profile keys `sites.partners_file`, `skill-publisher.partners_file`,
       `partners.file`, or `workspace.partners_file`.
    4. `app/(main)/(home)/PartnersGrid.tsx`, then legacy
       `app/(main)/(home)/WorkshopDispatch.tsx`.
    
    `SKILL_PARTNERS_FILE` and `PARTNERS_FILE` are accepted as legacy aliases,
    but should not be the public contract for reusable skills.
    
    For details and supported profile keys, read `references/user-config.md`.
    
    ## Skill Dependencies
    
    - `lov-find-logo` is required for all logo discovery. This skill must
      not scrape homepages itself or keep a separate fallback crawler.
    - Use the `depends_on` frontmatter field to declare skill-level dependencies.
      This mirrors the `depends_on` field in `lov-general-skills/skills.yaml`;
      unknown frontmatter keys are expected to be ignored by agents that do not
      consume dependency metadata.
    
    ## When to Use
    
    - User asks to **add** one or more new partners (with or without a logo URL).
    - User asks to **standardize / normalize** a logo (sizing wrong, white-on-white, etc.).
    - User provides a local file and asks to **replace** an existing partner's logo.
    - User asks to **audit** the partners section before a release.
    
    ## Standards
    
    - Logo canvas: **80px** content height for the website partners strip
      (light grayscale, CSS `height: 32px` ≈ 2.5× density, sharp enough),
      **240px** for event posters or any retina export at `scale: 2` or higher.
    - For white-on-transparent logos: invert (full or selective) so they show on
      the light grayscale strip.
    - For icon-only logos < ~40px wide after normalization: pass `--show-name`
      when adding so the brand name renders next to the icon.
    - Tagline format: `<品牌名> · <一句话定位>` in Chinese; mirror style in en/ja/th.
    
    ## Workflow
    
    ### Op 1: Add a new partner
    
    1. Ask the user for the brand name + homepage URL via `AskUserQuestion`.
    2. Collect the logo with `lov-find-logo`:
       ```bash
       python3 "$SKILL_ROOT/lov-find-logo/scripts/find_logo.py" \
         --name "<显示名>" --url <URL> --slug <slug> --json
       ```
       Use the archived primary asset under
       `~/.skill-publisher/logo-collection/<slug>/logo.<ext>`. If `find_logo.py` returns
       no candidates, stop and ask the user for a better official URL / press-kit
       URL, then rerun `find_logo.py`. Do not call a local scraper from this skill.
    3. Visually verify the archived primary asset before normalizing.
    4. If the primary asset is SVG, rasterize it before normalization:
       ```bash
       rsvg-convert -h 240 ~/.skill-publisher/logo-collection/<slug>/logo.svg \
         -o /tmp/<slug>-raw.png
       ```
       Use the rasterized `/tmp/<slug>-raw.png` as `--src`. For non-SVG sources,
       use the archived primary asset directly.
    5. Normalize:
       ```bash
       python3 "$SKILL_DIR/scripts/normalize_logo.py" \
         --src <archived-or-rasterized-logo> \
         --dst "$WEB_ROOT/public/partners/<slug>/logo.png" \
         --invert auto
       ```
    6. Read the normalized PNG to confirm it's visible (not white-on-white).
    7. Append to PARTNERS + all 4 locale JSONs:
       ```bash
       python3 "$SKILL_DIR/scripts/add_partner.py" \
         --repo "$WEB_ROOT" \
         --partners-file "$PARTNERS_TSX" \
         --name "<显示名>" --href "<URL>" \
         --logo "/partners/<slug>/logo.png" \
         --key partner<Slug>Tagline \
         --category community \
         --zh "..." --en "..." --ja "..." --th "..." \
         [--show-name]
       ```
    
    ### Op 2: Normalize an existing logo
    
    ```bash
    python3 "$SKILL_DIR/scripts/normalize_logo.py" \
      --src public/partners/<slug>/logo.png \
      --dst public/partners/<slug>/logo.png \
      --invert auto
    ```
    
    Re-read after to verify.
    
    ### Op 3: Replace logo from a user-provided file
    
    Ask for the source file path directly, or read it from the user's configured
    workspace/profile. Do not assume a private partners folder.
    
    ```bash
    python3 "$SKILL_DIR/scripts/normalize_logo.py" \
      --src "<user-provided path>" \
      --dst "$WEB_ROOT/public/partners/<slug>/logo.png" \
      --invert auto
    ```
    
    JPEG inputs auto-strip near-white background to transparent before crop.
    
    ### Op 4: Audit
    
    ```bash
    python3 "$SKILL_DIR/scripts/audit_partners.py" \
      --repo "$WEB_ROOT" \
      --partners-file "$PARTNERS_TSX"
    # add --probe to also HTTP-check every href (slow, requires proxy)
    ```
    
    Reports: missing logo files, missing i18n keys per locale, dead URLs.
    
    ### Op 5: Align a row of partner logos (cross-asset visual height parity)
    
    **When**: putting 3+ partner logos in a single horizontal strip and they look
    different sizes despite having the same CSS `height`. Common in event posters,
    hero sections, "联办 / co-host" rows.
    
    **Root cause**: each source file has different internal padding (designer
    canvas margin), so two PNGs both set to `height: 24px` render at different
    *visible* heights because their content occupies different fractions of the
    canvas. Per-logo CSS height tweaks based on eyeballed content ratios are
    unstable—different displays / scaling will diverge again.
    
    **Reliable fix — trim at file level, uniform CSS box**:
    
    1. **Normalize every logo** to identical content height. Default raster file
       target is **240px** (3× density for retina poster export at `scale: 2`;
       80px gives only 1.7× and looks soft after PNG export). Use `--invert off`
       if the source is already light-on-transparent (don't double-invert):
       ```bash
       for f in lujiazui juanyi citic-bookstore citic-thinker-lab; do
         python3 "$SKILL_DIR/scripts/normalize_logo.py" \
           --src "<configured-partners-source>/<brand>/<file>.png" \
           --dst <event-assets>/partners/$f.png \
           --height 240 --invert auto
       done
       ```
       **Always normalize from the original source**, never from a previously
       normalized 80px file (upscaling = blurry — burned by this on juanyi).
    
    2. **For SVG sources, rasterize first**. `normalize_logo.py` operates on
       raster pixels and **cannot crop SVG viewBox padding**. Without this step
       an SVG always renders smaller than rasterized PNG siblings:
       ```bash
       rsvg-convert -h 720 brand.svg -o /tmp/brand-raw.png   # 3× of 240
       python3 "$SKILL_DIR/scripts/normalize_logo.py" \
         --src /tmp/brand-raw.png --dst <event-assets>/partners/brand.png \
         --height 240 --invert off
       ```
       `rsvg-convert` ships with `librsvg` (`brew install librsvg`).
    
    3. **For SVG with embedded background rect** (icon wrapped in a black/colored
       rounded square — common in app-icon-style SVGs from `find-logo`), strip
       the background before rasterizing, otherwise filter `brightness(0)
       invert(1)` flattens it into a solid white block that hides the icon:
       ```bash
       # Drop the outer <rect fill="#000"...> wrapper
       sed -E 's|<rect[^/]*fill="#0+"[^/]*/>||' brand.svg > /tmp/brand-clean.svg
       rsvg-convert -h 720 /tmp/brand-clean.svg -o /tmp/brand-raw.png
       ```
    
    4. **Wrap each logo in a fixed-size box** (recommended over auto-width flex):
       ```html
       <span class="ps-logo-box"><img src="..." class="ps-logo"></span>
       ```
       ```css
       .ps-logo-box {
         width: 96px; height: 30px;             /* fixed grid cell */
         display: inline-flex;
         align-items: center; justify-content: center;
         border: 1px solid rgba(255,255,255,0.10);
         border-radius: 4px;
         padding: 3px 6px;
         box-sizing: border-box;
       }
       .ps-logo { max-width: 100%; max-height: 100%; width: auto; height: auto; display: block; }
       ```
       Fixed boxes give a stable matrix look — narrow logos (icon-only) and wide
       logos (icon + wordmark) all occupy the same footprint, with the asset
       scaled to fit. Auto-width flex (the older recipe) makes per-row total
       widths unpredictable as logos get added/removed.
    
    5. **Dark-background unification** — when the row sits on a dark canvas
       (e.g. event poster), most brand logos are designed for white BG and look
       inconsistent (some have black text, some have brand-colored marks). The
       stable recipe:
       ```css
       .ps-logo { filter: brightness(0) invert(1) opacity(0.88); }
       /* logos already white-on-transparent — opt out of inversion */
       .ps-logo.ps-logo-original { filter: opacity(0.88); }
       ```
       `brightness(0)` flattens all colors to black, then `invert(1)` produces
       uniform white at the configured opacity. The `.ps-logo-original` escape
       hatch is for source files that are already white-on-transparent (white
       SVG variants from a brand kit) so you don't double-process them into
       invisible black-on-dark.
    
    6. **Icon-only SVG → composite icon + wordmark** — if the brand SVG only
       has an icon (no "BrandName" wordmark beside it), don't ship just the icon
       in a 96×30 box (it'll look like an unidentified mark). Compose the
       wordmark with PIL using the brand's own font when possible:
    
       ```python
       from PIL import Image, ImageDraw, ImageFont, ImageOps
       # 1. rasterize cleaned SVG, invert white→black so default filter works
       icon = Image.open('/tmp/brand-icon.png').convert('RGBA')
       r, g, b, a = icon.split()
       inv = Image.merge('RGB', (ImageOps.invert(r), ImageOps.invert(g), ImageOps.invert(b)))
       icon = Image.merge('RGBA', (*inv.split(), a))
       icon = icon.crop(icon.getbbox())
       target_h = 240
       icon = icon.resize((int(icon.width * target_h / icon.height), target_h), Image.LANCZOS)
       # 2. render wordmark in brand font (find-logo bundles fonts/ when found)
       font = ImageFont.truetype('partners/<brand>/fonts/<Family>.ttf', 150)
       # 3. compose icon + gap + text on transparent canvas
       ```
       The PNG goes through the same `brightness(0) invert(1)` filter as raster
       logos — match colors with all other entries automatically. Use the brand's
       own font (often shipped under `<brand>/fonts/` by the find-logo skill);
       fall back to system SF / Helvetica only if no brand font is available.
    
    7. **Anti-pattern — do not** try to fix alignment by setting per-logo
       heights like `.ps-logo-juanyi { height: 26px }`. It's brittle (every new
       logo needs another magic number), unstable across browsers, and breaks
       the moment a designer reships the source asset with different padding.
    
    ## CLI Reference
    
    ### normalize_logo.py
    | Flag | Default | Notes |
    |---|---|---|
    | `--src` | required | input image (PNG/JPG/rasterized SVG) |
    | `--dst` | required | output PNG path; parent dirs auto-created |
    | `--height` | `80` | target content height. **Use 240 for retina poster export** (`scale: 2`) — 80 looks soft after 2× downscale. |
    | `--invert` | `auto` | `auto` / `off` / `full` / `selective` (selective preserves colored icons) |
    
    ### add_partner.py
    | Flag | Notes |
    |---|---|
    | `--repo` | website repo root; defaults to `SKILL_MAINTAIN_PARTNERS_SITE_ROOT`, profile JSON, or legacy `SKILL_WEB_ROOT` / `PARTNERS_SITE_ROOT` |
    | `--partners-file` | PARTNERS TSX file; defaults to `SKILL_MAINTAIN_PARTNERS_FILE`, profile JSON, legacy `SKILL_PARTNERS_FILE` / `PARTNERS_FILE`, PartnersGrid.tsx, or WorkshopDispatch.tsx |
    | `--name` | display name (CJK ok) |
    | `--href` | brand URL |
    | `--logo` | path under `/public`, e.g. `/partners/foo/logo.png` |
    | `--key` | i18n key, e.g. `partnerFooTagline` |
    | `--category` | `compute` / `peer` / `invest` / `media` / `community`; default `community` |
    | `--zh / --en / --ja / --th` | tagline strings (all required) |
    | `--show-name` | render name next to icon for narrow logos |
    
    ### audit_partners.py
    | Flag | Notes |
    |---|---|
    | `--repo` | website repo root; defaults to `SKILL_MAINTAIN_PARTNERS_SITE_ROOT`, profile JSON, or legacy `SKILL_WEB_ROOT` / `PARTNERS_SITE_ROOT` |
    | `--partners-file` | PARTNERS TSX file; defaults to `SKILL_MAINTAIN_PARTNERS_FILE`, profile JSON, legacy `SKILL_PARTNERS_FILE` / `PARTNERS_FILE`, PartnersGrid.tsx, or WorkshopDispatch.tsx |
    | `--probe` | HTTP-probe every href (slow, needs proxy env vars) |
    
    ## Network proxy
    
    Sandbox child processes don't inherit the system ClashX proxy. Before
    fetching logos with `lov-find-logo` or probing partner URLs, export:
    
    ```bash
    export https_proxy=http://127.0.0.1:7890 \
           http_proxy=http://127.0.0.1:7890 \
           all_proxy=socks5://127.0.0.1:7891
    ```
    
    `audit_partners.py` already injects these for `curl` invocations.
    
    ## Dependencies
    
    ```bash
    git clone https://example.com/skills/find-logo-skill \
      "${SKILL_SKILLS_INSTALL_DIR:?Set SKILL_SKILLS_INSTALL_DIR}/lov-find-logo"
    python3 -m pip install Pillow
    brew install librsvg  # for SVG logo sources
    ```
    
    ## Runtime context (shared)
    
    运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。
    
    - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
    - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。
    - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
    
    ## 通用反馈闭环
    
    用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
    
    1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。
    2. `task-specific` 只修改当前任务,不改 Skill。
    3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
    4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
    5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
    
  • skill.yaml 849 B
    schema: skill-manifest/v1
    id: lov-maintain-partners
    version: "0.10.0"
    runtime: skill-runtime/v1
    context:
      profile:
        fields:
        - path: identity.name
          required: true
          question: 如果本次输出需要品牌身份,请提供品牌名称。
        - path: identity.logo
          required: false
          question: 如果需要使用品牌 Logo,请提供 Logo 地址或文件路径。
        - path: brand.tone
          required: false
          question: 如果已有品牌语气或审美关键词,请提供它们。
      preferences:
        namespace: lov_maintain_partners
        fields:
        - path: user.language
          required: false
          question: 希望使用哪种语言输出?
        - path: user.timezone
          required: false
          question: 需要使用哪个时区处理日期和时间?
      interaction:
        ask_missing: true
        max_questions: 1
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related