lov-skill-publisher
Publish or submit a validated Skill. Default to the LovStudio official website, run lov-skill-pricing automatically, and use other channels only when explicitly named.
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/skill-publisher
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
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
Skill 发布助手 · Skill Publisher
把已经验证并安装在本地的 Skill 发布到一个或多个独立渠道。发布前默认调用 lov-skill-pricing 自动生成或刷新可解释定价,再把同一价格契约适配到各渠道,同时保持平台元数据和发行产物不污染源代码。
本地安装
在本仓库根目录执行:
export SKILL_SOURCE_DIR="$(pwd)"
mkdir -p "${SKILL_SKILLS_INSTALL_DIR:?请设置本地 Skills 目录}"
ln -s "$SKILL_SOURCE_DIR" \
"$SKILL_SKILLS_INSTALL_DIR/lov-skill-publisher"
也可以通过 Skills CLI 安装:
npx skills add lovstudio/skill-publisher-skill -g -y
当前适配器
| 渠道 | 交付结果 | 完成证据 |
|---|---|---|
| Skill Publisher | GitHub 源仓库、Release、目录和线上详情页 | 线上版本与内容可见 |
| 腾讯 WorkBuddy | Connector ZIP,可继续导入个人技能库 | 包校验、校验和及安装列表 |
| 支付宝 SkillPay | 商品 ZIP、人民币定价和审核提交 | 解析成功、提交回执与商品状态 |
其他平台通过 references/channels.md 的适配器契约扩展,并在实现时核对最新官方要求。
付费 Skill 必须在发布前明确交付模式:受保护源码使用已完成服务端登记和本地解密回归的
加密包;用户明确保留公开源码时使用 public_source: true,CLI 在权益检查后从公开真源安装。
只有付费目录卡和价格、却没有上述任一交付模式,不属于可发布状态。
global / all license 是动态全量权益:有效期内覆盖目录里所有仍上架的 Skill,
新 Skill 上架后自动纳入,不得在发放时展开成固定 Skill 清单;显式单项授权仍保持固定。
使用示例
- 直接调用
lov-skill-publisher:默认只发布到 LovStudio 官网。 - 只说“发布”也会先自动定价;用户明确给出的价格、币种和免费/付费状态会作为定价约束保留。
- “把这个本地 Skill 发布到 Skill Publisher。”
- “给这个 Skill 生成 WorkBuddy 包。”
- “把这个 Skill 按 ¥19.9 提交到 SkillPay。”
- “把这个 Skill 分发到全部渠道,并分别验证。”
质量门
python3 scripts/validate_skill.py . --target source
WorkBuddy 包:
python3 scripts/build_workbuddy.py SOURCE \
--meta CONNECTOR_META \
--icon ICON \
--output-dir OUTPUT_DIR
依赖
- Python 3.8+
- PyYAML
lov-skill-pricing(默认发布前自动定价)- Git 与 GitHub CLI(Skill Publisher 渠道)
- 各目标渠道所需凭据
License
MIT
Skill manifest
Skill 发布助手 · Skill Publisher
Publish one validated local Skill source to the LovStudio official website or explicitly selected additional channels. When the user does not specify a channel, run only the Skill Publisher website adapter. Always auto-price first, keep channel metadata and generated packages outside canonical source, and report evidence per selected channel.
Triggers
Activate when
- 用户说“发布这个 Skill”“上架 Skill Publisher”“生成 WorkBuddy 包”或“分发到多个平台”。
- The user asks to publish, release, distribute, upload, or package an existing Skill.
Do not activate when
- 用户要创建、实现、修改或仅在本地安装 Skill;交给
lov-skill-creator。 - 用户只是在调用某个业务 Skill,而不是发布它。
Product boundary
- Input is a local Skill source that already passes source validation.
- When no channel is specified, select only Skill Publisher, the LovStudio official website adapter.
- Explicitly named channels narrow the run; do not ask a channel-selection question when the request omits channel parameters.
- Run every supported adapter only when the user explicitly says “全部渠道”, “多平台”, “all channels”, or names every intended channel.
- A request may select multiple channels in one run.
- Pricing, visibility, protection, licensing, and target accounts are publishing inputs. Reuse context when known and ask only for values required by a target.
- A license scope named
globalorallis a dynamic entitlement to every currently listed Skill for the license lifetime. Never publish or migrate it as a frozen list of current Skill IDs; explicit per-Skill grants remain fixed. - Every publish run invokes
lov-skill-pricingby default to create or refresh one evidence-backed Pricing Card per Skill before channel preparation. An explicit user price is a constraint for that pricing pass, not a reason to skip it. - Keep the canonical Pricing Card in publisher profile/output storage. Transform only its public fields into catalogs, packages, or submission forms; do not add channel state or generated cards to canonical Skill source.
- Channel metadata, credentials, staging files, and archives stay outside source.
Supported adapters in this version:
- Skill Publisher — source repository, release, catalog, cache refresh, and live page.
- WorkBuddy(CodeBuddy 开放平台) — 生成独立 Skill ZIP,并在控制台上传、解析、填写上架信息和提交审核。
- Alipay SkillPay — validated product ZIP, explicit CNY price, upload, parse, submission, and observable review state.
For any additional platform, follow references/channels.md and verify its
current official name, submission contract, public URL, and completion signal
before implementing an adapter.
User Configuration
Publishing inherently needs persistent target settings. Initialize them on first
use through references/user-config.md, while keeping tokens in environment or
credential stores rather than committed profiles.
Workflow (MANDATORY)
Step 0: Resolve roots and settings
- Resolve this Skill as
SKILL_DIR. - Resolve the source from an explicit path, current directory, or conversation.
- Resolve target settings from flags, environment, and shared profile.
- Verify referenced scripts and channel documents before external changes.
Step 1: Validate canonical source
python3 "$SKILL_DIR/scripts/validate_skill.py" SOURCE --target source
Check that source has no platform metadata directory or generated release artifacts. Record name, version, description, modules, dependencies, git state, and whether a remote already exists.
Step 2: Auto-price the Skill
Use lov-skill-pricing for every source, even when the user only says “发布” or
selects a channel without mentioning price.
- Pass the validated source, version, delivery unit, real result evidence,
maintenance/support conditions, selected channels, and any existing price or
pricing-card.yamlinto the pricing workflow. - Let the pricing workflow use explicit assumptions when cost/value inputs are missing. Ask at most one focused question only when the missing field changes the commercial model; ordinary missing estimates must not block publication.
- If the user supplied a public price, currency, free/paid status, or billing model, preserve it as a hard publishing constraint and have the Pricing Card explain any difference from its model recommendation.
- Record recommended price, launch price, stable range, billing model, channel, cost floor, value anchor, weighted score, confidence, evidence gaps, and review trigger outside canonical source.
- Reuse this one price contract across selected adapters. Skill Publisher maps it into catalog pricing metadata and its public Pricing Card; SkillPay uses the public CNY price; installation-only channels use the free/paid funnel and upgrade path without inventing a separate price.
Do not hand-author an unexplained price inside a channel adapter. If automatic
pricing cannot run, mark pricing blocked with the missing Skill/resource and do
not submit a paid listing with an inferred number.
Step 3: Resolve channels and release model
If channels are explicit, proceed without another distribution question. If no channel is named, select only Skill Publisher and proceed without asking the user to choose a channel. Expand to all supported adapters only after an explicit all-channel or multi-platform request.
For each selected channel, resolve only required fields:
- public/private visibility and free/paid catalog status where supported;
- paid delivery mode: protected encrypted bundle or explicitly public source;
- organization, account, catalog, or output location;
- platform metadata, icon, examples, and source locator;
- requested version versus current source version.
Do not ask users to choose implementation details such as staging layout, validation commands, archive format, or adapter order.
Step 4: Build a per-channel plan
Read references/channels.md, then load only the selected channel references.
Keep independent state for each target so one failure does not masquerade as a
successful multi-channel release.
Step 5: Publish Skill Publisher
Read references/publishing.md completely. Execute the source repository,
release, catalog, cache refresh, and live verification workflow. Publication is
complete only when the expected version and release-specific content are visible
on the live detail page and the catalog's exact install command succeeds through
the declared delivery mode. A paid catalog entry without either a verified
encrypted bundle or explicit public_source: true is blocked, not published.
When a catalog refresh adds or delists a Skill, verify that dynamic global/all
licenses immediately gain or lose catalog access without granting or spending
Credits. This entitlement check is part of publication, not a later migration.
Step 6: Publish WorkBuddy through CodeBuddy
Read references/workbuddy.md completely. Keep connector metadata and icon in a
publisher profile outside source, then run:
python3 "$SKILL_DIR/scripts/build_workbuddy.py" SOURCE \
--meta CONNECTOR_META \
--icon ICON \
--output-dir OUTPUT_DIR
Record source validation, package validation, archive listing, checksum, module
count, and output paths. Then follow references/workbuddy.md: upload each
individual ZIP to https://www.codebuddy.cn/open/console/dashboard, wait for
“解析成功”, fill user-facing Chinese/English listing fields, and submit it.
Record the review state separately from public listing evidence.
Step 7: Submit Alipay SkillPay
Read references/skillpay.md completely. Build a clean product ZIP outside the
canonical source, keep the current Pricing Card price as the single public CNY
price, then upload and wait for parsing to finish before submitting. Record the
product title, package checksum, public price, submission result, and current
review state. A parsed archive is only uploaded; a success notice after form
submission is review until the marketplace marks the product live.
Step 8: Additional platform adapter
Use the adapter contract in references/channels.md. Research current official
documentation, implement deterministic preparation/validation scripts when
useful, and define an observable completion gate. Never reuse another channel's
metadata or call an upload dialog a completed publication.
Step 9: Multi-channel report
Report each target separately:
| Channel | State | Version/artifact | Evidence | Follow-up |
|---|---|---|---|---|
| TARGET | prepared/published/verified | VALUE | URL or local path | ACTION |
Use precise states. prepared, uploaded, installed, listed, and live
represent different outcomes.
Dependencies
- Python 3.8+
- PyYAML
lov-skill-pricingfor the default pre-publish Pricing Cardgitandghfor GitHub-backed publication- Target-specific credentials resolved without printing secrets
Local development
Validate this publisher Skill with:
python3 scripts/validate_skill.py . --target source
Runtime context (shared)
运行前读取本 Skill 包的 skill.yaml,由宿主提供 skill-runtime/v1 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。
- 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
required: true字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。- 报错提供可复制的
context_id、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
通用反馈闭环
用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
- 先判断意见是
task-specific(仅本次)还是reusable(可跨任务复用)。 task-specific只修改当前任务,不改 Skill。reusable先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。- 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
reusable修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
-
references
-
channels.md 2.7 KB
# Channel Adapter Contract Each publishing channel is an independent adapter. A multi-channel request runs selected adapters separately and aggregates evidence only at the end. When the user does not name a channel, select only the Skill Publisher adapter for the LovStudio official website. Select all supported channels only after an explicit all-channel or multi-platform request. ## Required adapter fields Before implementing or executing a new adapter, establish: 1. Stable channel ID and current official display name. 2. Official documentation and public product URL. 3. Whether the result is a package, personal-library import, private listing, public marketplace listing, or live hosted page. 4. Required account, credentials, source locator, metadata, icon, examples, and commercial fields. 5. The `lov-skill-pricing` Pricing Card that supplies the shared public price, billing model, free/paid funnel, confidence, evidence gaps, and review trigger. 6. Delivery mode: free source, protected encrypted bundle, or explicitly public paid source. Paid catalog visibility by itself is not an installable delivery. 7. Entitlement behavior: explicit Skill grants are fixed, while scopes named `global` or `all` dynamically cover every currently listed Skill and must not be expanded into a snapshot of current IDs. 8. Deterministic preparation and validation commands. 9. External actions such as repository creation, upload, review, or submission. 10. Observable completion evidence and rollback path. ## Adapter directory convention Keep channel state outside canonical Skill source: ```text <publisher-profile-root>/ └── <channel-id>/ └── <skill-name>/ ├── metadata.json ├── icon.svg └── release.json <publisher-output-root>/ └── <channel-id>/<skill-name>/<version>/ ``` Do not add platform frontmatter, credentials, upload state, or generated archives to the source repository. ## Execution states - `prepared`: metadata and artifacts exist locally and pass local validation. - `uploaded`: bytes reached a target account or submission endpoint. - `installed`: a personal library shows the Skill as usable. - `listed`: a marketplace record exists. - `live`: intended users can access the expected public version. - `verified`: the adapter-specific completion gate passed. Never collapse these states into a generic success label. ## Adding a channel Use current official sources when platform names, URLs, schemas, review policies, or APIs may have changed. Add one focused reference and deterministic helper only after the official contract and completion signal are known. Illustrative user names for a platform are discovery clues, not final identifiers. -
publishing.md 5.1 KB
# Publishing to Skill Publisher This adapter turns validated local source into a GitHub-backed Skill Publisher release, catalog entry, and verified live detail page. ## Inputs - Local Skill source path. - GitHub organization and desired repository visibility. - The current `lov-skill-pricing` Pricing Card, including free/paid status, public CNY price or free-entry decision, confidence, and review trigger. - The intended delivery mode for a paid Skill: protected encrypted bundle or explicitly public source. - Unified `lovstudio/skills` catalog checkout. - Expected version and a release-specific visible marker. - Revalidation secret resolved as `LOVSTUDIO_REVALIDATE_SECRET` without printing it. ## Source repository and release From the validated source directory: ```bash python3 scripts/validate_skill.py . git init -b main # only when source has no repository git add -A git commit -m "feat: initial release" gh repo create ORG/NAME-skill --VISIBILITY --source=. --push git tag vVERSION git push origin vVERSION gh release create vVERSION --generate-notes ``` For an existing repository, preserve its branch and history, update from the remote first, commit only intended changes, then tag from the verified commit. ## Paid delivery contract Resolve paid delivery before changing the catalog: - **Protected delivery:** set `encrypted_bundle: true`; generate and locally decrypt the current-version bundle; register its server-side version manifest and key; copy the placeholder, `MANIFEST.enc.json`, and ciphertext into the catalog mirror. Never print or commit the decryption key. - **Public-source delivery:** use only when the user explicitly keeps the paid Skill's source public. Set `public_source: true`, keep the repository public, and let the CLI install from the catalog-declared `repo` after its normal account ownership or Credits check. This mode provides no source secrecy and must not be described as encrypted protection. Block publication when `paid: true` has neither a complete encrypted bundle nor explicit `public_source: true`. A paid card, synced Credits price, or live detail page is not delivery evidence. ## License entitlement contract - `global` and `all` mean a dynamic license scope: while the license is active, they include every Skill that is currently listed in the canonical catalog. - A new Skill becomes available to existing global licenses during catalog sync; no per-license grant row, Credits balance, or repurchase is required. - A delisted Skill leaves the dynamic global scope. Explicit historical per-Skill purchases remain fixed unless their own license is revoked. - Never implement `global` by materializing the current catalog into a fixed list of Skill IDs. Catalog sync must maintain the listed/delisted state that the entitlement resolver reads. - Publication verification for a new paid Skill must cover three paths: a dynamic global license installs without Credits, an explicit owner installs without duplicate Credits, and an unentitled account reaches the purchase confirmation before source download. ## Catalog registration Use the unified `lovstudio/skills` catalog; the former split General and Dev catalogs are archived. Choose the entry category from product policy, not source location. Transform the current Pricing Card's public fields into the catalog manifest; do not independently invent or revise a price in this adapter. Add the repository, version, category, description, and paid status, then run the catalog's official mirror/render/validation scripts. Merge the catalog change into its `main` branch before live revalidation. For a public-source paid Skill, add `public_source: true` and keep it out of the aggregate plaintext mirror; the CLI installs the declared source repository directly after entitlement. For a protected paid Skill, add `encrypted_bundle: true` only after the committed encrypted files and registered server version are both ready. For generated aggregate catalogs, update metadata and run their official sync and render scripts. Do not hand-edit generated mirror directories. ## Revalidate Replace `NAME` and the site URL with configured values: ```bash test -n "$LOVSTUDIO_REVALIDATE_SECRET" curl -fsS -X POST "SITE_URL/api/revalidate" \ -H "x-revalidate-secret: $LOVSTUDIO_REVALIDATE_SECRET" \ -H "content-type: application/json" \ -d '{ "tags":[ "skills-index", "skills-index:lovstudio", "skills-updates", "skill:NAME", "skill-cases:NAME" ], "paths":["/skills","/skills/NAME","/agent"] }' ``` ## Verify the visible result ```bash curl -fsS -o /tmp/lov-skill-page.html \ -w '%{http_code}\n' "SITE_URL/skills/NAME" rg -n 'Version|EXPECTED_VERSION|EXPECTED_MARKER' \ /tmp/lov-skill-page.html ``` Completion requires the intended catalog to list the Skill, the detail page to return HTTP 200, the visible version plus marker to match the release, and the exact catalog install command to pass from a clean isolated directory. For paid delivery, verify with an already-owned test account so the run proves source selection without a duplicate purchase. -
skillpay.md 2.1 KB
# Alipay SkillPay Distribution Standard Use this adapter when the user selects 支付宝 SkillPay or requests all configured channels. SkillPay is a paid-product submission channel: packaging, upload, parsing, form submission, review, and live listing are separate states. ## Inputs - A canonical Skill source that passes `scripts/validate_skill.py`. - Product title and concise user-facing description. - Public CNY price from the current `lov-skill-pricing` Pricing Card. When the user supplied an explicit price, the card preserves it as a publishing constraint and explains any difference from its model recommendation. - Current version and source locator for release traceability. - An authenticated SkillPay merchant session. ## Product package Create the archive outside canonical source. Use one root directory named after the Skill and include the complete runnable source while excluding `.git`, local credentials, caches, build outputs, and channel profiles. Test the ZIP before upload and record its SHA-256 checksum. ## Submission workflow 1. Open `https://skillpay.alipay.com/ais/products` in the configured merchant browser session. 2. Start a new product submission and select the Skill ZIP through the page's file chooser. 3. Wait until the page explicitly reports that Skill parsing has completed. 4. Review parsed metadata, then fill the public title, description, and exact CNY price from the release manifest generated from the current Pricing Card. 5. Submit the form and wait for the product-success notice. 6. Re-open the product list and record whether the product is reviewing or live. Do not expose browser credentials, session data, or merchant identifiers in logs. Do not report `review` before the form submission succeeds, and do not report `live` until the public product state shows it. ## Completion evidence - `prepared`: ZIP integrity, file count, and checksum recorded. - `uploaded`: the page reports parsing completed. - `review`: the submission success notice is visible and the product appears in the merchant list with a review state. - `live`: the marketplace exposes a purchasable public product entry. -
user-config.md 1.3 KB
# Publisher User Configuration Publishing needs persistent channel settings, while secrets remain in environment variables or credential stores. ## First-run initialization 1. Prefill source roots and target accounts from the current request. 2. Read environment variables and the shared profile. 3. Infer safe local output directories. 4. Ask once only for required channel values still missing. 5. Show non-secret values before saving them to the profile. ## Shared profile ```bash ${SKILL_PROFILE_PATH:-$HOME/.skill-publisher/skills/profile.json} ``` Recommended shape: ```json { "publisher": { "github_org": "YOUR_ORG", "default_visibility": "private", "output_dir": "$HOME/Documents/skill-releases", "skill-publisher": { "catalog": "$HOME/projects/skills", "catalog_repo": "lovstudio/skills", "site_url": "https://example.com/skills" }, "workbuddy": { "profile_dir": "$HOME/.skill-publisher/skills/publish/workbuddy" } } } ``` ## Secret handling - Use `gh auth` for GitHub credentials. - Resolve revalidation and platform tokens from environment or a credential store. - Never print, persist into the shared JSON profile, or copy secrets into source. - Channel adapters should name the missing environment variable without echoing its value. -
workbuddy.md 2.8 KB
# WorkBuddy(CodeBuddy 开放平台)发布标准 This adapter builds a self-contained Connector ZIP from portable local source. Platform metadata, icon, staging directory, and archives stay outside source. ## Inputs ```text <local-source>/ ├── SKILL.md ├── README.md ├── kit.yaml # Skill Kit only └── skills/ # Skill Kit only <publisher-profile>/workbuddy/<skill-name>/ ├── connector-meta.json └── icon.svg ``` `connector-meta.json` includes: - `name`, `name_zh`, `name_en` - `description`, `description_zh`, `description_en` - globally unique kebab-case `source` - `type: "skill-only"` - SemVer `version` - `source_type` plus one supported source locator - two to five Chinese and English examples - `minWorkbuddyVersion` when a version-gated field is used For an update to an existing WorkBuddy listing whose historical internal Skill ID differs from the canonical source ID, set optional `raw_name` to that exact legacy ID. The builder applies it only to the root WorkBuddy package directory and frontmatter; canonical source and user-facing display names stay unchanged. Display names should contain 2–20 characters. Chinese and English descriptions should each contain 20–100 characters and describe the user outcome. ## Package transformation The builder copies portable source to staging and converts package frontmatter to WorkBuddy fields without mutating canonical source. For Skill Kits, every declared module is included and every named pipeline must reference known module IDs. ## Build Choose a new output path for each build: ```bash python3 "$SKILL_DIR/scripts/build_workbuddy.py" SOURCE \ --meta PUBLISHER_PROFILE/connector-meta.json \ --icon PUBLISHER_PROFILE/icon.svg \ --output-dir OUTPUT_DIR ``` The builder emits a combined Connector ZIP and individual Skill ZIPs, validates the staged package, and rejects broken references, private paths, placeholders, caches, compiled Python artifacts, missing metadata, or missing modules. ## Evidence ## CodeBuddy 上架 1. 打开 `https://www.codebuddy.cn/open/console/dashboard`,选择“上架新 Skill”。 2. 上传 builder 输出的 `*-individual/<skill-name>.zip`;聚合 Connector ZIP 不用于单项上架。 3. 等待“解析成功”,填写中英文展示名与用户价值描述,提交审核。 4. 记录审核中、已上架或已驳回状态;微信群审核沟通不等同于公开上架证据。 Record source version and commit when available, source and package validation, ZIP paths and SHA-256 checksums, archive listing, module count, metadata, and icon presence. `prepared` means the package exists and passes local gates. If personal-library import is requested, continue until the installed list shows the Skill. Public marketplace publication requires separate evidence from the public marketplace.
-
-
scripts
-
build_workbuddy.py 11.2 KB
#!/usr/bin/env python3 """Build a self-contained WorkBuddy Connector ZIP from local Skill source.""" from __future__ import annotations import argparse import hashlib import json import shutil import sys import zipfile from pathlib import Path from typing import Any try: from validate_skill import ( ValidationFailure, compact_text, split_frontmatter, validate_connector_meta, validate_source, validate_workbuddy_package, ) except ImportError as exc: print( "ERROR: scripts/validate_skill.py must be present next to this builder", file=sys.stderr, ) raise SystemExit(2) from exc RESOURCE_DIRS = ("assets", "cases", "prompts", "references", "scripts", "skills") RESOURCE_FILES = ("kit.yaml",) SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules", "__pycache__"} SKIP_FILES = { ".DS_Store", "build_workbuddy.py", "validate_skill.py", } SKIP_SUFFIXES = {".pyc", ".pyo"} PUBLISHER_SOURCE = Path(__file__).resolve().parent.parent def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "source", nargs="?", type=Path, default=Path.cwd(), help="Skill source repository (default: current directory)", ) parser.add_argument( "--meta", type=Path, required=True, help="External WorkBuddy connector-meta.json", ) parser.add_argument( "--icon", type=Path, required=True, help="External market icon (.svg, .png, .jpg, or .jpeg)", ) parser.add_argument( "--output-dir", type=Path, required=True, help="New staging directory to create", ) parser.add_argument( "--zip-path", type=Path, help="ZIP destination (default: OUTPUT_DIR.zip)", ) parser.add_argument( "--individual-dir", type=Path, help="Directory for independently installable Skill ZIPs", ) return parser.parse_args() def fail_on_errors(errors: list[str], label: str) -> None: if errors: raise ValueError(f"{label} failed:\n- " + "\n- ".join(errors)) def ignore_resources(_directory: str, names: list[str]) -> set[str]: ignored = set() for name in names: path = Path(name) if ( name in SKIP_DIRS or name in SKIP_FILES or path.suffix.lower() in SKIP_SUFFIXES ): ignored.add(name) return ignored def copy_source_resources(source: Path, target: Path) -> None: shutil.copy2(source / "SKILL.md", target / "SKILL.md") for filename in RESOURCE_FILES: candidate = source / filename if candidate.is_file(): shutil.copy2(candidate, target / filename) for dirname in RESOURCE_DIRS: candidate = source / dirname if candidate.is_dir(): # The publisher is itself a distributable Skill. Its validation and # WorkBuddy-builder scripts are runtime dependencies, while those # same files remain distribution tooling for every other Skill. ignore = None if source.resolve() == PUBLISHER_SOURCE else ignore_resources shutil.copytree( candidate, target / dirname, ignore=ignore, ) def make_module_self_contained(source: Path, target: Path) -> None: """Copy shared kit resources into a standalone module package. The combined entrypoint keeps `$KIT_DIR` semantics. WorkBuddy also emits every module as an independently installable Skill, so those siblings need their own copy of shared references/assets/scripts and local `$SKILL_DIR` references. """ for dirname in ("assets", "cases", "prompts", "references", "scripts"): candidate = source / dirname if candidate.is_dir(): shutil.copytree( candidate, target / dirname, dirs_exist_ok=True, ignore=ignore_resources, ) for path in target.rglob("*.md"): original = path.read_text(encoding="utf-8") updated = original.replace("$KIT_DIR/", "$SKILL_DIR/") if updated != original: path.write_text(updated, encoding="utf-8") def workbuddy_frontmatter( source_skill: Path, connector: dict[str, Any], root_source_name: str, ) -> tuple[str, str]: data, body = split_frontmatter(source_skill) metadata = data.get("metadata") if not isinstance(metadata, dict): raise ValidationFailure(f"{source_skill}: metadata must be a mapping") source_name = compact_text(data.get("name")) raw_name = compact_text(connector.get("raw_name")) package_name = ( raw_name if raw_name and source_name == root_source_name else source_name ) fields: list[tuple[str, str]] = [ ("name", package_name), ("description", compact_text(data.get("description"))), ("version", compact_text(metadata.get("version"))), ("author", compact_text(metadata.get("author"))), ("source_type", compact_text(connector.get("source_type"))), ] for locator in ("clawhub_slug", "skillhub_slug", "git_url"): value = compact_text(connector.get(locator)) if value: fields.append((locator, value)) lines = ["---"] for key, value in fields: lines.append(f"{key}: {json.dumps(value, ensure_ascii=False)}") lines.extend(("---", "")) return "\n".join(lines), body def transform_skills( target: Path, connector: dict[str, Any], root_source_name: str, ) -> None: for skill_file in sorted(target.rglob("SKILL.md")): frontmatter, body = workbuddy_frontmatter( skill_file, connector, root_source_name, ) skill_file.write_text(frontmatter + body, encoding="utf-8") def write_zip(output_dir: Path, zip_path: Path) -> None: if zip_path.exists(): raise FileExistsError(f"ZIP already exists: {zip_path}") zip_path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: for path in sorted(output_dir.rglob("*")): if path.is_file(): archive.write( path, Path(output_dir.name) / path.relative_to(output_dir), ) def write_individual_zips(skills_dir: Path, individual_dir: Path) -> None: if individual_dir.exists(): raise FileExistsError( f"individual ZIP directory already exists: {individual_dir}" ) individual_dir.mkdir(parents=True) for skill_dir in sorted(skills_dir.iterdir()): if not (skill_dir / "SKILL.md").is_file(): continue zip_path = individual_dir / f"{skill_dir.name}.zip" with zipfile.ZipFile( zip_path, "w", compression=zipfile.ZIP_DEFLATED ) as archive: for path in sorted(skill_dir.rglob("*")): if path.is_file(): archive.write( path, Path(skill_dir.name) / path.relative_to(skill_dir), ) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def build( source: Path, meta_path: Path, icon_path: Path, output_dir: Path, zip_path: Path, individual_dir: Path, ) -> None: source_errors: list[str] = [] validate_source(source, source_errors, require_self_contained=True) validate_connector_meta(meta_path, source_errors) if not icon_path.is_file(): source_errors.append(f"{icon_path}: market icon is required") elif icon_path.suffix.lower() not in {".svg", ".png", ".jpg", ".jpeg"}: source_errors.append(f"{icon_path}: unsupported market icon format") fail_on_errors(source_errors, "WorkBuddy input validation") if output_dir.exists(): raise FileExistsError(f"output directory already exists: {output_dir}") if zip_path.exists(): raise FileExistsError(f"ZIP already exists: {zip_path}") if individual_dir.exists(): raise FileExistsError( f"individual ZIP directory already exists: {individual_dir}" ) connector = json.loads(meta_path.read_text(encoding="utf-8")) source_data, _ = split_frontmatter(source / "SKILL.md") root_source_name = compact_text(source_data.get("name")) root_skill_name = compact_text(connector.get("raw_name")) or root_source_name output_dir.mkdir(parents=True) shutil.copy2(meta_path, output_dir / "connector-meta.json") shutil.copy2(icon_path, output_dir / f"icon{icon_path.suffix.lower()}") skills_target = output_dir / "skills" skill_target = skills_target / root_skill_name skill_target.mkdir(parents=True) copy_source_resources(source, skill_target) source_modules = source / "skills" if source_modules.is_dir(): for module_source in sorted(source_modules.iterdir()): module_skill = module_source / "SKILL.md" if not module_skill.is_file(): continue module_data, _ = split_frontmatter(module_skill) module_name = compact_text(module_data.get("name")) module_target = skills_target / module_name module_target.mkdir(parents=True) copy_source_resources(module_source, module_target) make_module_self_contained(source, module_target) transform_skills(skills_target, connector, root_source_name) package_errors: list[str] = [] validate_workbuddy_package(output_dir, package_errors) fail_on_errors(package_errors, "WorkBuddy package validation") write_zip(output_dir, zip_path) write_individual_zips(skills_target, individual_dir) print(f"connector_dir={output_dir}") print(f"connector_zip={zip_path}") print(f"connector_sha256={sha256(zip_path)}") print(f"individual_zips={individual_dir}") print(f"entrypoint={root_skill_name}") print( "top_level_skills=" f"{len(list(skills_target.glob('*/SKILL.md')))}" ) print("validation=passed") def main() -> int: args = parse_args() source = args.source.expanduser().resolve() meta_path = args.meta.expanduser().resolve() icon_path = args.icon.expanduser().resolve() output_dir = args.output_dir.expanduser().resolve() zip_path = ( args.zip_path.expanduser().resolve() if args.zip_path else output_dir.parent / f"{output_dir.name}.zip" ) individual_dir = ( args.individual_dir.expanduser().resolve() if args.individual_dir else output_dir.parent / f"{output_dir.name}-individual" ) if not source.is_dir(): print(f"ERROR: source directory does not exist: {source}", file=sys.stderr) return 2 try: build(source, meta_path, icon_path, output_dir, zip_path, individual_dir) except (FileExistsError, ValidationFailure, ValueError) as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 20.1 KB
#!/usr/bin/env python3 """Validate Skill Publisher source Skills and WorkBuddy distributions.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required for standards-compliant frontmatter parsing. " "Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) SOURCE_FRONTMATTER_KEYS = { "name", "description", "license", "compatibility", "allowed-tools", "metadata", } WORKBUDDY_FRONTMATTER_KEYS = { "name", "description", "version", "author", "source_type", "clawhub_slug", "skillhub_slug", "git_url", } SOURCE_LOCATORS = ("clawhub_slug", "skillhub_slug", "git_url") TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile( r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)" ) class ValidationFailure(Exception): """Raised when a validation input cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") raw = text[4:marker] try: data = yaml.safe_load(raw) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a YAML mapping") return data, text[marker + 5 :] def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def validate_name(name: Any, label: str, errors: list[str]) -> None: if not isinstance(name, str) or not NAME_RE.fullmatch(name): errors.append(f"{label}: name must be kebab-case") elif len(name) > 64: errors.append(f"{label}: name must be at most 64 characters") def validate_description( value: Any, label: str, errors: list[str], minimum: int = 50, maximum: int = 200, ) -> None: description = compact_text(value) if not minimum <= len(description) <= maximum: errors.append( f"{label}: description must contain {minimum}-{maximum} characters " f"(found {len(description)})" ) if "<" in description or ">" in description: errors.append(f"{label}: description must not contain angle brackets") def validate_triggers(body: str, label: str, errors: list[str]) -> None: if not re.search(r"(?mi)^##\s+Triggers\s*$", body): errors.append(f"{label}: add an explicit '## Triggers' section") if not re.search( ( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|" r"不应触发|不要触发)\s*$" ), body, ): errors.append( f"{label}: Triggers must include explicit non-trigger conditions" ) trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if trigger_block and len(re.findall(r"(?m)^\s*-\s+\S", trigger_block.group(1))) < 3: errors.append( f"{label}: Triggers should include at least two activation examples " "and one non-trigger condition" ) def validate_frontmatter( path: Path, target: str, errors: list[str] ) -> tuple[dict[str, Any], str] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None allowed = ( SOURCE_FRONTMATTER_KEYS if target == "source" else WORKBUDDY_FRONTMATTER_KEYS ) unexpected = sorted(set(data) - allowed) if unexpected: errors.append( f"{path}: unsupported {target} frontmatter keys: " + ", ".join(unexpected) ) validate_name(data.get("name"), str(path), errors) validate_description(data.get("description"), str(path), errors) validate_triggers(body, str(path), errors) if not body.strip(): errors.append(f"{path}: body is empty") if target == "source": metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") version = compact_text(metadata.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") else: version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{path}: version must use SemVer") if not compact_text(data.get("author")): errors.append(f"{path}: author is required") if data.get("source_type") not in {"git", "clawhub", "skillhub"}: errors.append( f"{path}: source_type must be git, clawhub, or skillhub" ) if not any(compact_text(data.get(key)) for key in SOURCE_LOCATORS): errors.append( f"{path}: add one source locator: " + ", ".join(SOURCE_LOCATORS) ) return data, body def validate_todos(paths: Iterable[Path], errors: list[str]) -> None: for path in paths: if path.is_file() and re.search(r"\bTODO\s*[::]", read_text(path)): errors.append(f"{path}: unresolved TODO placeholder") def validate_junk(root: Path, errors: list[str]) -> None: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not be released") def validate_private_paths(root: Path, errors: list[str]) -> None: private_path = re.compile( r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\\\s]+\\\\)" ) for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES: continue if path.name == "validate_skill.py": continue if private_path.search(read_text(path)): errors.append(f"{path}: contains a private absolute user path") def normalize_link_target(raw: str) -> str: target = raw.strip().split(maxsplit=1)[0].strip("<>") return target.split("#", 1)[0] def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = normalize_link_target(raw) if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or target.startswith("#") or any(token in target for token in ("TODO", "{", "}")) ): continue resolved = (path.parent / target).resolve() if not resolved.exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent current = path.parent while current != root.parent: if (current / "SKILL.md").is_file(): skill_root = current break if current == root: break current = current.parent kit_root = skill_root current = path.parent while current != root.parent: if (current / "kit.yaml").is_file(): kit_root = current break if current == root: break current = current.parent for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else kit_root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append( f"{path}: missing or external required resource " f"'${variable}/{target}'" ) def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label} must be a mapping") continue module_id = compact_text(module.get("id")) skill = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) if skill not in skill_names: errors.append(f"{label}: unresolved child skill '{skill}'") module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append( f"{label}: missing self-contained module at '{relative}/SKILL.md'" ) else: try: module_data, _ = split_frontmatter(module_path / "SKILL.md") except ValidationFailure as exc: errors.append(str(exc)) else: actual_name = compact_text(module_data.get("name")) if actual_name != skill: errors.append( f"{label}: expected Skill name '{skill}' at " f"'{relative}/SKILL.md', found '{actual_name}'" ) pipelines = data.get("pipelines") if pipelines is not None: if not isinstance(pipelines, dict): errors.append(f"{manifest}: pipelines must be a mapping") else: for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append( f"{manifest}: pipeline '{pipeline}' must be a non-empty list" ) continue missing = [item for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(map(str, missing)) ) def validate_dependencies( skill_data: list[tuple[Path, dict[str, Any]]], names: set[str], errors: list[str], require_self_contained: bool, ) -> None: for path, data in skill_data: metadata = data.get("metadata") if not isinstance(metadata, dict): continue dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") continue for dependency in dependencies: name = ( dependency if isinstance(dependency, str) else dependency.get("skill") if isinstance(dependency, dict) else "" ) if not name: errors.append(f"{path}: dependency entries need a skill name") elif not isinstance(name, str) or not NAME_RE.fullmatch(name): errors.append(f"{path}: dependency names must be kebab-case") elif require_self_contained and name not in names: errors.append( f"{path}: unresolved required dependency '{name}'; " "embed it in the Skill Kit before release" ) def validate_readme_version( root: Path, root_data: dict[str, Any] | None, errors: list[str] ) -> None: readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: required for Skill Publisher source repositories") return if root_data is None: return metadata = root_data.get("metadata") if not isinstance(metadata, dict): return version = compact_text(metadata.get("version")) if version and f"version-{version}-" not in read_text(readme): errors.append( f"{readme}: version badge does not match SKILL.md version {version}" ) def validate_source( root: Path, errors: list[str], require_self_contained: bool = False, ) -> None: skill_files = [root / "SKILL.md", *sorted((root / "skills").glob("*/SKILL.md"))] if not skill_files[0].is_file(): errors.append(f"{skill_files[0]}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] root_data: dict[str, Any] | None = None for path in skill_files: result = validate_frontmatter(path, "source", errors) if result: data, _ = result parsed.append((path, data)) if path == root / "SKILL.md": root_data = data names = { compact_text(data.get("name")) for _, data in parsed if compact_text(data.get("name")) } if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") validate_dependencies(parsed, names, errors, require_self_contained) validate_kit(root, names, errors) validate_readme_version(root, root_data, errors) validate_todos([root / "SKILL.md", root / "README.md", *skill_files[1:]], errors) validate_junk(root, errors) validate_private_paths(root, errors) validate_local_references(root, errors) if (root / "workbuddy").exists(): errors.append( f"{root / 'workbuddy'}: keep platform metadata outside canonical source" ) def validate_connector_meta(path: Path, errors: list[str]) -> dict[str, Any] | None: if not path.is_file(): errors.append(f"{path}: required for WorkBuddy distribution") return None try: data = json.loads(read_text(path)) except json.JSONDecodeError as exc: errors.append(f"{path}: invalid JSON: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a JSON object") return None required = ( "name", "name_zh", "name_en", "description", "description_zh", "description_en", "source", "type", "version", "examples_zh", "examples_en", "source_type", ) for field in required: if field not in data: errors.append(f"{path}: missing '{field}'") if not 2 <= len(compact_text(data.get("name"))) <= 20: errors.append(f"{path}: name should contain 2-20 characters") for field in ("description_zh", "description_en"): value = compact_text(data.get(field)) if not 20 <= len(value) <= 100: errors.append(f"{path}: {field} should contain 20-100 characters") source = compact_text(data.get("source")) if not NAME_RE.fullmatch(source): errors.append(f"{path}: source must be a globally unique kebab-case ID") raw_name = compact_text(data.get("raw_name")) if raw_name and not NAME_RE.fullmatch(raw_name): errors.append(f"{path}: raw_name must be a kebab-case Skill ID") if data.get("type") != "skill-only": errors.append(f"{path}: type must be 'skill-only'") if not SEMVER_RE.fullmatch(compact_text(data.get("version"))): errors.append(f"{path}: version must use SemVer") if data.get("source_type") not in {"git", "clawhub", "skillhub"}: errors.append(f"{path}: source_type must be git, clawhub, or skillhub") if not any(compact_text(data.get(key)) for key in SOURCE_LOCATORS): errors.append( f"{path}: add one source locator: " + ", ".join(SOURCE_LOCATORS) ) for field in ("examples_zh", "examples_en"): examples = data.get(field) if not isinstance(examples, list) or not 2 <= len(examples) <= 5: errors.append(f"{path}: {field} must contain 2-5 examples") if re.search(r"\bTODO\s*[::]", read_text(path)): errors.append(f"{path}: unresolved TODO placeholder") return data def validate_workbuddy_package(root: Path, errors: list[str]) -> None: validate_connector_meta(root / "connector-meta.json", errors) icons = [root / f"icon{suffix}" for suffix in (".svg", ".png", ".jpg", ".jpeg")] if not any(path.is_file() for path in icons): errors.append(f"{root}: missing market icon") skills_root = root / "skills" skill_files = sorted(skills_root.glob("*/SKILL.md")) if not skill_files: errors.append(f"{skills_root}: package must contain at least one Skill") return names: set[str] = set() for path in sorted(skills_root.rglob("SKILL.md")): result = validate_frontmatter(path, "workbuddy", errors) if result: data, _ = result name = compact_text(data.get("name")) names.add(name) for root_skill in skill_files: validate_kit(root_skill.parent, names, errors) validate_junk(root, errors) validate_private_paths(root, errors) validate_local_references(root, errors) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Skill source or package directory") parser.add_argument( "--target", choices=("source", "workbuddy-package"), default="source", help="Validation profile", ) return parser.parse_args() def main() -> int: args = parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] if args.target == "source": validate_source(root, errors) else: validate_workbuddy_package(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: {args.target} validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
.gitignore 84 B · in bundle
-
CHANGELOG.md 3.3 KB
# Changelog ## [0.7.2] - 2026-08-27 ### Fixed - define `global` / `all` licenses as dynamic access to every currently listed Skill instead of a frozen grant snapshot - require publication verification to cover dynamic global, explicit ownership, and unentitled Credits paths ## [0.7.1] - 2026-08-27 ### Fixed - require paid Skill Publisher entries to declare and verify either protected encrypted delivery or explicit public-source delivery before publication ## [0.7.0] - 2026-08-24 ### Added - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill ## [0.6.0] - 2026-08-24 ### Changed - default channel-less publishing requests to the LovStudio official website - run all supported adapters only for explicit all-channel or multi-platform requests - preserve automatic `lov-skill-pricing` as the mandatory pre-publish step - use the current `LOVSTUDIO_REVALIDATE_SECRET` variable in website revalidation guidance ## [0.5.0] - 2026-08-14 ### Added - support legacy WorkBuddy raw names - allow external raw_name metadata for updates to existing WorkBuddy listings without changing canonical Skill IDs ## [0.4.1] - 2026-08-14 ### Fixed - target the unified lovstudio/skills catalog - replace archived General and Dev catalog settings and cache tags ## [0.4.0] - 2026-08-14 ### Added - default every publishing run to evidence-backed automatic pricing - reuse one lov-skill-pricing Pricing Card across Skill Publisher, WorkBuddy, and SkillPay adapters - treat explicit user prices as publishing constraints and fix the Skill Publisher reference path ## [0.3.3] - 2026-08-11 ### Fixed - 补齐发布器 compatibility 元数据和 Skills CLI 安装入口 ## [0.3.2] - 2026-08-11 ### Fixed - 让 source validator 接受标准 compatibility 元数据 ## [0.3.1] - 2026-08-10 ### Changed - 未指定渠道时默认运行全部支持的发布适配器;显式渠道参数仍可收窄发布范围。 ## [0.3.0] - 2026-08-03 ### Added - 接入 CodeBuddy WorkBuddy 上架流程 - 使用独立 ZIP、解析成功与审核状态作为发布证据 ## [0.2.2] - 2026-08-03 ### Added - 修复 WorkBuddy 子 Skill 的本地资源校验 - 按最近 SKILL.md 解析打包后 references 中的 SKILL_DIR 路径 ## [0.2.1] - 2026-08-03 ### Added - 修复 Publisher 自身的 WorkBuddy 自包含打包 - 发布 Publisher 时保留其运行所需的校验与打包脚本 ## [0.2.0] - 2026-08-03 ### Added - 统一为 publisher 角色命名 - 同步 Creator 与 Distiller 的发布交接名称 ## 0.1.1 - Add Alipay SkillPay as a first-class adapter with explicit package, upload, review, and live completion gates. - Make WorkBuddy Skill Kit modules self-contained by copying shared resources and localizing `$KIT_DIR` references in standalone module packages. - Preserve the full SemVer suffix when deriving a Connector ZIP name from an output directory such as `v0.2.0`. ## 0.1.0 - Add an independent multi-channel publishing workflow for validated local Skills. - Support Skill Publisher source, release, catalog, revalidation, and live verification. - Support external-profile WorkBuddy Connector packaging and import evidence. - Keep channel metadata and generated artifacts outside canonical Skill source. - Define a provider adapter contract for future official distribution channels. -
LICENSE 1 KB · in bundle
-
README.md 2.6 KB
# Skill 发布助手 · Skill Publisher  把已经验证并安装在本地的 Skill 发布到一个或多个独立渠道。发布前默认调用 `lov-skill-pricing` 自动生成或刷新可解释定价,再把同一价格契约适配到各渠道,同时保持平台元数据和发行产物不污染源代码。 ## 本地安装 在本仓库根目录执行: ```bash export SKILL_SOURCE_DIR="$(pwd)" mkdir -p "${SKILL_SKILLS_INSTALL_DIR:?请设置本地 Skills 目录}" ln -s "$SKILL_SOURCE_DIR" \ "$SKILL_SKILLS_INSTALL_DIR/lov-skill-publisher" ``` 也可以通过 Skills CLI 安装: ```bash npx skills add lovstudio/skill-publisher-skill -g -y ``` ## 当前适配器 | 渠道 | 交付结果 | 完成证据 | |------|----------|----------| | Skill Publisher | GitHub 源仓库、Release、目录和线上详情页 | 线上版本与内容可见 | | 腾讯 WorkBuddy | Connector ZIP,可继续导入个人技能库 | 包校验、校验和及安装列表 | | 支付宝 SkillPay | 商品 ZIP、人民币定价和审核提交 | 解析成功、提交回执与商品状态 | 其他平台通过 `references/channels.md` 的适配器契约扩展,并在实现时核对最新官方要求。 付费 Skill 必须在发布前明确交付模式:受保护源码使用已完成服务端登记和本地解密回归的 加密包;用户明确保留公开源码时使用 `public_source: true`,CLI 在权益检查后从公开真源安装。 只有付费目录卡和价格、却没有上述任一交付模式,不属于可发布状态。 `global` / `all` license 是动态全量权益:有效期内覆盖目录里所有仍上架的 Skill, 新 Skill 上架后自动纳入,不得在发放时展开成固定 Skill 清单;显式单项授权仍保持固定。 ## 使用示例 - 直接调用 `lov-skill-publisher`:默认只发布到 LovStudio 官网。 - 只说“发布”也会先自动定价;用户明确给出的价格、币种和免费/付费状态会作为定价约束保留。 - “把这个本地 Skill 发布到 Skill Publisher。” - “给这个 Skill 生成 WorkBuddy 包。” - “把这个 Skill 按 ¥19.9 提交到 SkillPay。” - “把这个 Skill 分发到全部渠道,并分别验证。” ## 质量门 ```bash python3 scripts/validate_skill.py . --target source ``` WorkBuddy 包: ```bash python3 scripts/build_workbuddy.py SOURCE \ --meta CONNECTOR_META \ --icon ICON \ --output-dir OUTPUT_DIR ``` ## 依赖 - Python 3.8+ - PyYAML - `lov-skill-pricing`(默认发布前自动定价) - Git 与 GitHub CLI(Skill Publisher 渠道) - 各目标渠道所需凭据 ## License MIT -
SKILL.md 11.1 KB
--- name: lov-skill-publisher description: > Publish or submit a validated Skill. Default to the LovStudio official website, run lov-skill-pricing automatically, and use other channels only when explicitly named. license: MIT compatibility: >- Requires Python 3.8+, PyYAML, git and GitHub CLI for the Skill Publisher adapter. Channel credentials stay in environment variables or credential stores; generated metadata and archives stay outside canonical source. metadata: author: contributors version: "0.7.2" tags: - skill-publisher - release - marketplace - workbuddy - skillpay dependencies: - lov-skill-pricing --- # Skill 发布助手 · Skill Publisher Publish one validated local Skill source to the LovStudio official website or explicitly selected additional channels. When the user does not specify a channel, run only the Skill Publisher website adapter. Always auto-price first, keep channel metadata and generated packages outside canonical source, and report evidence per selected channel. ## Triggers ### Activate when - 用户说“发布这个 Skill”“上架 Skill Publisher”“生成 WorkBuddy 包”或“分发到多个平台”。 - The user asks to publish, release, distribute, upload, or package an existing Skill. ### Do not activate when - 用户要创建、实现、修改或仅在本地安装 Skill;交给 `lov-skill-creator`。 - 用户只是在调用某个业务 Skill,而不是发布它。 ## Product boundary - Input is a local Skill source that already passes source validation. - When no channel is specified, select only **Skill Publisher**, the LovStudio official website adapter. - Explicitly named channels narrow the run; do not ask a channel-selection question when the request omits channel parameters. - Run every supported adapter only when the user explicitly says “全部渠道”, “多平台”, “all channels”, or names every intended channel. - A request may select multiple channels in one run. - Pricing, visibility, protection, licensing, and target accounts are publishing inputs. Reuse context when known and ask only for values required by a target. - A license scope named `global` or `all` is a dynamic entitlement to every currently listed Skill for the license lifetime. Never publish or migrate it as a frozen list of current Skill IDs; explicit per-Skill grants remain fixed. - Every publish run invokes `lov-skill-pricing` by default to create or refresh one evidence-backed Pricing Card per Skill before channel preparation. An explicit user price is a constraint for that pricing pass, not a reason to skip it. - Keep the canonical Pricing Card in publisher profile/output storage. Transform only its public fields into catalogs, packages, or submission forms; do not add channel state or generated cards to canonical Skill source. - Channel metadata, credentials, staging files, and archives stay outside source. Supported adapters in this version: - **Skill Publisher** — source repository, release, catalog, cache refresh, and live page. - **WorkBuddy(CodeBuddy 开放平台)** — 生成独立 Skill ZIP,并在控制台上传、解析、填写上架信息和提交审核。 - **Alipay SkillPay** — validated product ZIP, explicit CNY price, upload, parse, submission, and observable review state. For any additional platform, follow `references/channels.md` and verify its current official name, submission contract, public URL, and completion signal before implementing an adapter. ## User Configuration Publishing inherently needs persistent target settings. Initialize them on first use through `references/user-config.md`, while keeping tokens in environment or credential stores rather than committed profiles. ## Workflow (MANDATORY) ### Step 0: Resolve roots and settings - Resolve this Skill as `SKILL_DIR`. - Resolve the source from an explicit path, current directory, or conversation. - Resolve target settings from flags, environment, and shared profile. - Verify referenced scripts and channel documents before external changes. ### Step 1: Validate canonical source ```bash python3 "$SKILL_DIR/scripts/validate_skill.py" SOURCE --target source ``` Check that source has no platform metadata directory or generated release artifacts. Record name, version, description, modules, dependencies, git state, and whether a remote already exists. ### Step 2: Auto-price the Skill Use `lov-skill-pricing` for every source, even when the user only says “发布” or selects a channel without mentioning price. 1. Pass the validated source, version, delivery unit, real result evidence, maintenance/support conditions, selected channels, and any existing price or `pricing-card.yaml` into the pricing workflow. 2. Let the pricing workflow use explicit assumptions when cost/value inputs are missing. Ask at most one focused question only when the missing field changes the commercial model; ordinary missing estimates must not block publication. 3. If the user supplied a public price, currency, free/paid status, or billing model, preserve it as a hard publishing constraint and have the Pricing Card explain any difference from its model recommendation. 4. Record recommended price, launch price, stable range, billing model, channel, cost floor, value anchor, weighted score, confidence, evidence gaps, and review trigger outside canonical source. 5. Reuse this one price contract across selected adapters. Skill Publisher maps it into catalog pricing metadata and its public Pricing Card; SkillPay uses the public CNY price; installation-only channels use the free/paid funnel and upgrade path without inventing a separate price. Do not hand-author an unexplained price inside a channel adapter. If automatic pricing cannot run, mark pricing `blocked` with the missing Skill/resource and do not submit a paid listing with an inferred number. ### Step 3: Resolve channels and release model If channels are explicit, proceed without another distribution question. If no channel is named, select only Skill Publisher and proceed without asking the user to choose a channel. Expand to all supported adapters only after an explicit all-channel or multi-platform request. For each selected channel, resolve only required fields: - public/private visibility and free/paid catalog status where supported; - paid delivery mode: protected encrypted bundle or explicitly public source; - organization, account, catalog, or output location; - platform metadata, icon, examples, and source locator; - requested version versus current source version. Do not ask users to choose implementation details such as staging layout, validation commands, archive format, or adapter order. ### Step 4: Build a per-channel plan Read `references/channels.md`, then load only the selected channel references. Keep independent state for each target so one failure does not masquerade as a successful multi-channel release. ### Step 5: Publish Skill Publisher Read `references/publishing.md` completely. Execute the source repository, release, catalog, cache refresh, and live verification workflow. Publication is complete only when the expected version and release-specific content are visible on the live detail page and the catalog's exact install command succeeds through the declared delivery mode. A paid catalog entry without either a verified encrypted bundle or explicit `public_source: true` is blocked, not published. When a catalog refresh adds or delists a Skill, verify that dynamic `global`/`all` licenses immediately gain or lose catalog access without granting or spending Credits. This entitlement check is part of publication, not a later migration. ### Step 6: Publish WorkBuddy through CodeBuddy Read `references/workbuddy.md` completely. Keep connector metadata and icon in a publisher profile outside source, then run: ```bash python3 "$SKILL_DIR/scripts/build_workbuddy.py" SOURCE \ --meta CONNECTOR_META \ --icon ICON \ --output-dir OUTPUT_DIR ``` Record source validation, package validation, archive listing, checksum, module count, and output paths. Then follow `references/workbuddy.md`: upload each individual ZIP to `https://www.codebuddy.cn/open/console/dashboard`, wait for “解析成功”, fill user-facing Chinese/English listing fields, and submit it. Record the review state separately from public listing evidence. ### Step 7: Submit Alipay SkillPay Read `references/skillpay.md` completely. Build a clean product ZIP outside the canonical source, keep the current Pricing Card price as the single public CNY price, then upload and wait for parsing to finish before submitting. Record the product title, package checksum, public price, submission result, and current review state. A parsed archive is only `uploaded`; a success notice after form submission is `review` until the marketplace marks the product live. ### Step 8: Additional platform adapter Use the adapter contract in `references/channels.md`. Research current official documentation, implement deterministic preparation/validation scripts when useful, and define an observable completion gate. Never reuse another channel's metadata or call an upload dialog a completed publication. ### Step 9: Multi-channel report Report each target separately: | Channel | State | Version/artifact | Evidence | Follow-up | |---------|-------|------------------|----------|-----------| | TARGET | prepared/published/verified | VALUE | URL or local path | ACTION | Use precise states. `prepared`, `uploaded`, `installed`, `listed`, and `live` represent different outcomes. ## Dependencies - Python 3.8+ - PyYAML - `lov-skill-pricing` for the default pre-publish Pricing Card - `git` and `gh` for GitHub-backed publication - Target-specific credentials resolved without printing secrets ## Local development Validate this publisher Skill with: ```bash python3 scripts/validate_skill.py . --target source ``` ## 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 845 B
schema: skill-manifest/v1 id: lov-skill-publisher version: "0.7.2" runtime: skill-runtime/v1 context: profile: fields: - path: identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: identity.logo required: false question: 如果需要使用品牌 Logo,请提供 Logo 地址或文件路径。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_skill_publisher 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.
Reviews (0)
No reviews yet.
No comments yet.