lov-gen-logo
根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入。支持明确输入与结果回读。Use to create a logo and
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/gen-logo
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
Logo 工坊 · Logo Studio
迭代式 Logo 生成,区分透明品牌标志与满铺应用图标,并验证视觉占比
The portable source keeps the iterative logo workflow and separates user-owned brand assets from the Skill logic.
安装
npx skills add lov-gen-logo -g -y
Invocation
使用 lov-gen-logo 完成以下任务:<任务目标、输入和约束>
Status
- Version: 5.1.2
官网安装
npx -y lovstudio@latest skills add gen-logo
Skill manifest
Logo 工坊 · Logo Studio
迭代生成 Logo,保留每一版原稿,并将“品牌标志”和“应用图标”作为不同产物处理。
Triggers
Activate when
- 用户说“生成 Logo”“设计应用图标”“去掉 Logo 白底”或“放大图标主体”。
- 用户要求迭代、选用或发布某一版 Logo。
- The user asks to create, refine, select, or publish a logo or app icon.
Do not activate when
- 用户只想查找已有官方 Logo;交给 Logo 查找类 Skill。
- 用户只想压缩、裁剪或转换普通图片,且不涉及品牌标志设计。
核心原则
- 不覆盖:每次生成保存为新版本
v{N}-{描述}。 - 项目驱动:根据产品功能、使用场景和品牌气质设计,不按项目名字面联想。
- 分离产物:透明品牌标志、应用图标和平台派生资源分别制作,禁止相互冒充。
- 视觉占比优先:用可见图形边界判断大小,不用源文件画布或透明留白判断。
- 真实场景验收:在 favicon、桌面、Dock、主屏幕等最终使用环境验证视觉重量。
- 确认发布:用户确认后再写入正式位置;仅在
--publish时提交。
参数
/lov-gen-logo [concept] [--use vN] [--publish vN]
concept:设计概念或迭代反馈。--use vN:选用第 N 版并更新正式资产,不提交。--publish vN:选用第 N 版、完成平台验证并提交。
产物模型
为每版保留以下文件;按项目实际用途省略不需要的派生项:
assets/logo-drafts/
├── vN-desc-raw.png # 生成器原图,永不覆盖
├── vN-desc-mark.png # 紧边界透明标志
├── vN-desc-mark.svg # 矢量标志
├── vN-desc-app-icon-mark.png # 1024 方形透明布局稿,用于测量主体占比
├── vN-desc-app-icon.png # 1024 方形、不透明、满铺底色的应用图标
└── vN-desc-preview.png # 小尺寸与明暗背景预览
区分以下语义:
| 产物 | 背景 | 留白 | 用途 |
|---|---|---|---|
mark.png/svg |
透明 | 紧边界或少量光学留白 | 网站、文档、组合品牌 |
app-icon-mark.png |
透明 | 主体长边通常占画布 62%–74% | 测量和合成中间稿 |
app-icon.png |
不透明且铺满画布 | 不在外缘留透明或白色边框 | iOS、桌面应用图标 |
| Android adaptive icon | 前景与背景分层 | 遵循平台安全区 | Android 启动器 |
除非设计明确要求白色底板,否则不要把白底视为 Logo 的组成部分。iOS 圆角由系统裁切,不要预先烘焙圆角或透明四角。
工作流程
Step 0:发现项目与版本
- 读取
package.json、README.md和现有品牌资产。 - 搜索平台事实源,例如
app.json、Info.plist、asset catalog、PWA manifest、Electron/Tauri 图标配置。 - 检查
assets/logo-drafts/的最大版本号,使用下一版本。 - 记录现有正式 Logo 路径,禁止默认假设一定存在
assets/或public/。
mkdir -p ./assets/logo-drafts
find ./assets/logo-drafts -maxdepth 1 -type f -name 'v[0-9]*-*' \
-print | sort -V | tail -1
Step 1:分析项目与设计概念
首次生成时读取:
package.json的名称、描述、关键词和依赖;名称只作标识。README.md的核心功能、目标用户和差异化价值。- 当前界面的形状语言、颜色、圆角、图标密度和相邻品牌资产。
按项目类型选择方向:
| 类型 | 识别特征 | 推荐方向 |
|---|---|---|
| Web 框架/Starter | next、react、vue、angular | 几何抽象、模块、网格 |
| CLI 工具 | commander、yargs、bin |
光标、尖锐线条、技术感 |
| 桌面/移动应用 | electron、tauri、expo、原生工程 | 单一强轮廓、应用图标构图 |
| 库/SDK | 无 UI 依赖、纯逻辑 | 极简符号、数学感 |
| AI/ML | openai、langchain、模型依赖 | 连接、流动、智能但避免通用星芒 |
迭代版本应先总结上一版的明确问题,例如“主体只占画布 46%,在主屏幕上显小”,再把改进目标写进 Prompt。不要只写“更大”或“更好看”。
Step 2:生成原稿
${IMAGE_GENERATOR_COMMAND:-image-generator} \
"PROMPT" -o ./assets/logo-drafts/v{N}-{desc}-raw.png -q high
Prompt 需要明确:
- 只生成独立标志,不含文字、展示样机、边框、卡片和预制圆角底板。
- 使用 2–3 个主要几何形状,在 16×16 仍可辨认。
- 保持轮廓完整、边缘干净、构图居中。
- 生成器不稳定支持透明度时,使用与标志颜色明显不同的纯色背景,后续提取;不要让背景成为设计元素。
- 应用图标的底色、渐变和光效在标志提取后确定性合成。
优先读取 Profile 的 brand.colors.primary 或项目已有品牌色;二者都缺少时,先询问本次视觉方向需要的颜色,不在 Skill 内设定某个品牌专属默认色。
Step 3:提取透明标志并矢量化
保留 raw.png,只对副本去背景。优先从四角连通区域移除背景,避免使用全局 -transparent white 误删标志内部的白色区域。
cd ./assets/logo-drafts
magick v{N}-{desc}-raw.png -alpha on \
-bordercolor white -border 1 -fuzz 5% -fill none \
-draw 'alpha 0,0 floodfill' -shave 1x1 \
-trim +repage PNG32:v{N}-{desc}-mark.png
magick v{N}-{desc}-mark.png -channel A -threshold 50% +channel temp.png
vtracer --input temp.png --output v{N}-{desc}-mark.svg \
--mode spline --filter_speckle 8 --color_precision 8 \
--corner_threshold 120 --segment_length 6 --path_precision 5
npx svgo v{N}-{desc}-mark.svg -o v{N}-{desc}-mark.svg --multipass
rm -f temp.png
背景不是白色时,将 -bordercolor white 替换为实际角落背景色。提取后放大检查轮廓,不接受白色毛边、半透明光晕、断裂细节或棋盘格伪透明。
Step 4:构建应用图标
从透明 mark.png 确定性合成应用图标,不直接把带透明留白的 Logo 当作 App Icon。
$SKILL_DIR/scripts/build_app_icon.sh \
--mark ./assets/logo-drafts/v{N}-{desc}-mark.png \
--output ./assets/logo-drafts/v{N}-{desc}-app-icon.png \
--layout-output ./assets/logo-drafts/v{N}-{desc}-app-icon-mark.png \
--background '#0C150F' \
--occupancy 0.68
以可见主体长边占画布 68% 为起点,根据视觉重量在 62%–74% 内调整:
- 细线、镂空、尖角或浅色标志取较大值。
- 密实、圆润、高对比标志取较小值。
- 低于
60%通常会在主屏幕上显小;高于78%通常会显拥挤。 - 比较相邻常见应用图标的视觉重量,不只比较几何宽高。
为渐变背景传入 --background-image PATH。保持背景满铺到四角,输出 PNG 必须为 1024×1024、RGB、无 alpha。
Android 项目应另外生成 adaptive icon 的前景和背景层,并在圆形、方形、圆角方形遮罩下预览;不要直接复用 iOS 合成图。
Step 5:质量门禁与展示
生成预览并同时检查:
- 透明标志置于浅色、深色和棋盘格背景。
- 应用图标以 16、32、60、120、256 像素显示。
- 应用图标与 2–3 个同平台常见图标并排,检查主体视觉重量。
- 背景铺满四角;非刻意白底时不存在白色方块或白边。
- SVG 与 PNG 的形状、方向、颜色一致。
执行基础检查:
magick identify -format '%f %wx%h %[channels]\n' \
v{N}-{desc}-mark.png v{N}-{desc}-app-icon.png
magick v{N}-{desc}-app-icon.png \
-format 'corners: %[pixel:p{0,0}] %[pixel:p{1023,0}] %[pixel:p{0,1023}] %[pixel:p{1023,1023}]\n' \
info:
使用图像查看工具展示 mark.png、app-icon.png 和 preview.png,再反馈版本、主体占比、背景语义和改进点。
✓ v{N}-{desc} 生成完成
主体长边占画布:{ratio}%
产物:透明标志 / 不透明应用图标 / 场景预览
继续迭代可直接描述具体问题;确认后执行 /lov-gen-logo --publish v{N}
Step 6:选用与发布
执行 --use 或 --publish 时:
- 读取平台配置,建立“草稿产物 → 正式事实源”的明确映射。
- 只写入项目真实存在或配置引用的路径;禁止无条件创建
assets/logo.*和public/logo.*。 - 运行项目原有的图标生成流程,验证最终打包资源而不只验证源 PNG。
- Web 项目验证 favicon、PWA manifest 和浅深色背景。
- 桌面应用验证 Dock、任务栏、菜单栏和安装包图标。
- 移动应用构建并安装到真实设备,在主屏幕与相邻图标并排验证;安装成功不等于图标验收完成。
- 用户确认版本后,
--publish仅提交本次 Logo 相关文件。
git status --short
git add <本次确认的正式资产> <对应草稿版本>
git commit -m "docs: update logo to v{N}-{desc}"
Step 7:生成 Tray Icon(Tauri)
存在 src-tauri/icons/ 时,从紧边界透明标志生成模板图标:
magick ./assets/logo-drafts/v{N}-{desc}-mark.png -trim +repage \
-resize 38x38 -gravity center -background transparent -extent 56x44 \
-colorspace gray -fill white -colorize 100% \
src-tauri/icons/tray-icon.png
在 macOS 明暗菜单栏分别检查,不把彩色应用图标直接缩小作为菜单栏模板图标。
依赖
python3与图像生成后端- ImageMagick 7(
magick) vtracersvgo
附带脚本
scripts/build_app_icon.sh:按可见主体占比构建 1024×1024 的透明布局稿和不透明应用图标,并校验尺寸与 alpha。
Execution boundary
自然语言请求即可触发;无需旧 slash 路径、参数插值或指定助手。明确解析当前请求中的 项目、目标文件、选项与输出位置;用当前宿主实际提供的文件、搜索、CLI 和浏览器能力。 项目依赖版本与外部 API 在执行时核实,不能假设示例是现行配置。随包脚本从 Skill 根解析, 业务文件从目标项目根解析。先读当前状态,保护已有未提交内容与其他任务的暂存区。 分析、预览请求保持只读;修改、提交、推送、部署和发布各依当前请求的明确范围执行。 不绕过保护、自动发送消息、强制结束用户进程或抢前台。失败保留可诊断原始错误。
Composition
执行前读取 能力组合,按明确制品交接相邻能力。
Runtime context (shared)
运行前读取本包 skill.yaml 与 Profile 合同。优先级为当前请求、
项目上下文、本 Skill records、共享 preferences、brand/user Profile、安全默认值。
只读取声明字段;没有专用运行时的宿主可使用 scripts/profile_store.py 读取共享 Profile。
配置缺失只问影响结果的一个问题。用户明确要求长期保存的值通过该脚本原子写入,
报告实际路径;不保存推断、凭据或其他任务的资料。
Files (skills)
-
cases
-
cases.json 653 B
[ { "title": "历史命令通用化迁移验证", "description": "真实迁移请求的结构与可发现性验收;业务系统操作没有在本案例执行。", "input": { "source": "历史命令或本地已迁移 Skill", "sha256": "6989a80a6ba3d0462231f28771d1b914857cdb6b23e221a929eaf78c31adcea8" }, "prompt": "将本地所有历史 slash command 逐一升级,并全部同步官网。", "output": { "artifact": "SKILL.md", "result": "显式输入、自然语言触发、跨宿主执行边界及共享 Profile 合同", "limitation": "该案例不证明外部业务写入成功" } } ]
-
-
references
-
skill-composition.md 867 B
# Skill Group Composition ## Nearby Skills Inspected - lov-skill-creator:命令迁移与通用结构;不拥有本业务结果。 - lov-branding-consistency:用户可见文案审校。 - lov-skill-publisher:已验证 Skill 的官网分发。 ## Atomic Handoffs 本 Skill 接收请求与项目事实,交付根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入。文案审校只消费公开文本;发布器只消费校验后的 Skill 源,不接管业务执行。 ## Overlap Decisions 旧命令按实际输入输出映射到本真源;不新增另一份重复业务实现。其他历史能力在本批次逐项比较,只有用户要求相邻结果时交接。 ## Composition Decision Single Skill:围绕同一结果的预览、执行和验证共享上下文;不把依赖工具或宿主适配误拆成 Kit。 -
user-profile.md 2.7 KB
# User Profile contract Every Skill created by Skill Creator declares `user-profile/v1` in `skill.yaml`. The contract connects independent sessions to one user-owned JSON Profile while keeping the Skill source portable across users and brands. ## Shared shape The host supplies the Profile through `SKILL_PROFILE_PATH` (or the runtime's configured profile path). The stable shared scopes are: - `user`: user identity, language, timezone, and other personal working defaults. - `brand`: public brand facts, site, logo, tone, profile, and design guidance. - `workspace`: project roots and output locations. - `preferences`: shared preference values when the host stores them in the Profile. - `skills.<skill_id>.profile`: Skill-specific defaults. - `skills.<skill_id>.records`: durable decisions and preferences learned from direct user statements for this Skill. The Profile may also use the runtime's canonical `identity` fields. Manifest field aliases bridge `identity.*` and the portable `user.*` / `brand.*` names. ## Read on every run 1. Read the current request and project context. 2. Read the shared Profile and the `skills.<skill_id>` namespace. 3. Resolve values in this order: current request, project context, Skill records, shared preferences, shared user/brand Profile, safe defaults. 4. Keep `profile_scope` and field provenance available for the final result. Do not copy resolved personal paths, brand values, or private records into the committed Skill source. ## Persist directly stated values When the user explicitly gives a value meant to survive later sessions, save it immediately after the user statement and report the canonical path: ```bash python3 scripts/profile_store.py record \ --skill-id lov-example \ --path records.subtitle_level \ --value '"cet4"' \ --confirm ``` For shared facts, use `--path brand.<field>` or `--path user.<field>`. The script writes JSON atomically, preserves unrelated Profile data, increments a numeric Profile revision when present, and never echoes the stored value. Inferred information, credentials, tokens, cookies, and secret-like fields stay out of durable records. If the user has not stated that a value should persist, keep it in the current request context. ## Read the connected context ```bash python3 scripts/profile_store.py read \ --skill-id lov-example \ --pretty ``` The result contains `user`, `brand`, `workspace`, `preferences`, `skill`, and `records` scopes. A host using `skill-runtime/v1` also returns the same binding as `profile_scope` and `profile_contract`. ## Compatibility `--user-config` remains accepted by the Creator as a compatibility flag for old invocations. The Profile contract is now always generated; users do not choose an initialization mode.
-
-
scripts
-
build_app_icon.sh 4.4 KB
#!/usr/bin/env bash set -euo pipefail usage() { echo "Usage: $0 --mark PATH --output PATH --layout-output PATH (--background COLOR | --background-image PATH) [--occupancy 0.68] [--size 1024]" >&2 } mark_path="" output_path="" layout_output_path="" background_color="" background_image="" occupancy="0.68" canvas_size="1024" while [[ $# -gt 0 ]]; do case "$1" in --mark) mark_path="${2:-}" shift 2 ;; --output) output_path="${2:-}" shift 2 ;; --layout-output) layout_output_path="${2:-}" shift 2 ;; --background) background_color="${2:-}" shift 2 ;; --background-image) background_image="${2:-}" shift 2 ;; --occupancy) occupancy="${2:-}" shift 2 ;; --size) canvas_size="${2:-}" shift 2 ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage exit 2 ;; esac done if [[ -z "$mark_path" || -z "$output_path" || -z "$layout_output_path" ]]; then usage exit 2 fi if [[ ! -f "$mark_path" ]]; then echo "Mark file not found: $mark_path" >&2 exit 2 fi if [[ -n "$background_color" && -n "$background_image" ]]; then echo "Choose either --background or --background-image." >&2 exit 2 fi if [[ -z "$background_color" && -z "$background_image" ]]; then echo "A full-bleed background is required." >&2 exit 2 fi if [[ -n "$background_image" && ! -f "$background_image" ]]; then echo "Background image not found: $background_image" >&2 exit 2 fi if ! [[ "$canvas_size" =~ ^[0-9]+$ ]] || [[ "$canvas_size" -lt 16 ]]; then echo "--size must be an integer of at least 16." >&2 exit 2 fi if ! awk -v value="$occupancy" 'BEGIN { exit !(value >= 0.60 && value <= 0.78) }'; then echo "--occupancy must be between 0.60 and 0.78." >&2 exit 2 fi if ! command -v magick >/dev/null 2>&1; then echo "ImageMagick 7 is required." >&2 exit 2 fi mkdir -p "$(dirname "$output_path")" "$(dirname "$layout_output_path")" target_size="$(awk -v size="$canvas_size" -v ratio="$occupancy" 'BEGIN { printf "%d", (size * ratio) + 0.5 }')" mark_channels="$(magick identify -format '%[channels]' "$mark_path")" if [[ "$mark_channels" != *a* ]]; then echo "The mark must contain an alpha channel: ${mark_channels}." >&2 exit 2 fi read -r alpha_min alpha_max < <( magick "$mark_path" -alpha extract -format '%[fx:minima] %[fx:maxima]\n' info: ) if ! awk -v low="$alpha_min" -v high="$alpha_max" \ 'BEGIN { exit !(low <= 0.01 && high >= 0.5) }'; then echo "The mark must contain both transparent background and visible content." >&2 exit 2 fi magick "$mark_path" -alpha extract -threshold 1% -trim -format '%w %h\n' info: | { read -r trimmed_width trimmed_height if [[ "$trimmed_width" -lt 1 || "$trimmed_height" -lt 1 ]]; then echo "The mark has no visible alpha content." >&2 exit 2 fi } magick "$mark_path" -trim +repage \ -resize "${target_size}x${target_size}" \ -gravity center -background none -extent "${canvas_size}x${canvas_size}" \ "PNG32:${layout_output_path}" if [[ -n "$background_image" ]]; then magick "$background_image" \ -resize "${canvas_size}x${canvas_size}^" \ -gravity center -extent "${canvas_size}x${canvas_size}" \ "$layout_output_path" -compose over -composite \ -alpha remove -alpha off "PNG24:${output_path}" else magick -size "${canvas_size}x${canvas_size}" "canvas:${background_color}" \ "$layout_output_path" -compose over -composite \ -alpha remove -alpha off "PNG24:${output_path}" fi read -r output_width output_height output_channels < <( magick identify -format '%w %h %[channels]\n' "$output_path" ) if [[ "$output_width" != "$canvas_size" || "$output_height" != "$canvas_size" ]]; then echo "Unexpected output size: ${output_width}x${output_height}." >&2 exit 1 fi if [[ "$output_channels" == *a* ]]; then echo "App icon still contains alpha: ${output_channels}." >&2 exit 1 fi read -r visible_width visible_height < <( magick "$layout_output_path" -alpha extract -threshold 1% -trim -format '%w %h\n' info: ) actual_occupancy="$(awk -v w="$visible_width" -v h="$visible_height" -v size="$canvas_size" 'BEGIN { long = w > h ? w : h; printf "%.3f", long / size }')" echo "Built app icon: $output_path" echo "Layout proof: $layout_output_path" echo "Canvas: ${canvas_size}x${canvas_size}, visible mark occupancy: ${actual_occupancy}" -
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 26.2 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" 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. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = { "name", "description", "license", "compatibility", "allowed-tools", "depends_on", "metadata", } 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_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" CONTENT_CLASSES = { "authored-prose", "microcopy", "verbatim", "deterministic-output", } class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" 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") try: data = yaml.safe_load(text[4:marker]) 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 mapping") return data, text[marker + 5 :] 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 is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) compatibility = compact_text(data.get("compatibility")) if not compatibility: errors.append(f"{path}: compatibility is required at the top level") depends_on = data.get("depends_on", []) if isinstance(depends_on, str): depends_on = [depends_on] if not isinstance(depends_on, list) or not all( isinstance(item, str) and NAME_RE.fullmatch(item) for item in depends_on ): errors.append(f"{path}: depends_on must be a list of exact Skill names") 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") if not SEMVER_RE.fullmatch(compact_text(metadata.get("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") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") content_class = compact_text(metadata.get("content_class")) if content_class and content_class not in CONTENT_CLASSES: errors.append( f"{path}: metadata.content_class must be one of " f"{', '.join(sorted(CONTENT_CLASSES))}" ) if content_class in {"authored-prose", "microcopy"} and ( not isinstance(depends_on, list) or "lov-branding-consistency" not in depends_on ): errors.append( f"{path}: {content_class} requires lov-branding-consistency" ) if content_class == "authored-prose": authorship_reference = path.parent / "references" / "authorship-integrity.md" if not authorship_reference.is_file(): errors.append( f"{authorship_reference}: authored-prose contract is required" ) if "references/authorship-integrity.md" not in body: errors.append( f"{path}: authored-prose must route to references/authorship-integrity.md" ) trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data 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_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") 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}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = 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) 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 module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return 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 = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) 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 = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") 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 ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.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] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
CHANGELOG.md 421 B
# Changelog ## [5.1.2] - 2026-09-07 - 修正 README 的过期状态版本和本地安装路径,补充自然语言调用与官网安装命令。 ## [5.1.1] - 2026-09-07 - 将历史 slash command 升级为通用 Skill,补全自然语言触发、显式输入、共享 Profile 与验收边界。 - 保留业务目的,修正宿主耦合和不安全的隐式动作;迁移案例与实际业务验收分别记录。 -
LICENSE 1 KB · in bundle
-
pricing-card.yaml 425 B
schema: lovstudio/pricing-card/v1 model: free_entry currency: CNY list_price_cny: 0 basis: 轻量公开工具能力;当前证据主要是源码迁移与安装验证,采用免费入口积累业务结果。 boundary: 交付 Skill 源码与使用合同,不含第三方服务费用、账户或人工代运营。 review_trigger: 十次真实业务使用或依赖接口与维护成本明显变化后复评。 confidence: low -
README.md 561 B
# Logo 工坊 · Logo Studio  > 迭代式 Logo 生成,区分透明品牌标志与满铺应用图标,并验证视觉占比 The portable source keeps the iterative logo workflow and separates user-owned brand assets from the Skill logic. ## 安装 ```bash npx skills add lov-gen-logo -g -y ``` ## Invocation ``` 使用 lov-gen-logo 完成以下任务:<任务目标、输入和约束> ``` ## Status - Version: 5.1.2 ## 官网安装 ```bash npx -y lovstudio@latest skills add gen-logo ``` -
skill-card.md 1.2 KB
# Logo 工坊 · Logo Studio · Skill Card ## Description 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入 ## Owner contributors ## License MIT ## Use Case 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入 ## Deployment Geography 本地运行;外部系统仅按用户明确目标访问。 ## Requirements 参见 SKILL.md 的兼容性和工作流。 ## Known Risks 项目版本与依赖不同;结构验收不等于业务运行成功。 ## References [工作流](SKILL.md) · [组合关系](references/skill-composition.md) ## Skill Output 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入 ## Skill Version 5.1.2 ## Ethical Considerations 不发布秘密,不伪造来源与验证结果。 ## User Cases [迁移案例](cases/cases.json);此案例仅覆盖通用化合同。 ## Dimension Map 可移植性、配置隔离、可追溯性;未给未经测量的业务评分。 ## Pricing Basis 免费入口;[价值与边界](pricing-card.yaml)。 ## Distribution GitHub 与 LovStudio:prepared;WorkBuddy 与 SkillPay:未选择。 -
skill-card.yaml 1.6 KB
schema: lovstudio/skill-card/v1 description: 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入 owner: contributors license: MIT use_case: 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入 deployment: 本地 Agent 宿主,业务系统按任务指定 requirements: 可读取文件的 Agent;业务依赖见 SKILL.md risks: - risk: 不同项目与工具版本存在差异,迁移验证不能替代业务运行验证。 mitigation: 执行前发现实际环境,按工作流回读结果。 references: - SKILL.md - cases/cases.json - references/skill-composition.md output: 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入 version: 5.1.2 ethical_considerations: 保留来源、用户授权和秘密边界,不虚构案例或外部完成状态。 dimensions: - id: portability label: 宿主可移植性 description: 显式输入与实际宿主能力 evidence: SKILL.md 的 Triggers 和 Execution boundary score: null - id: context label: 配置隔离 description: 用户配置与公开源分离 evidence: skill.yaml 与 Profile 合同 score: null - id: traceability label: 结果可追溯 description: 原始输入摘要与迁移结果 evidence: cases/cases.json;尚无业务效果评分 score: null pricing: model: free_entry basis: pricing-card.yaml distribution: free: - channel: github status: prepared - channel: lovstudio status: prepared paid: - channel: workbuddy status: not_selected - channel: skillpay status: not_selected -
SKILL.md 11.4 KB
--- name: lov-gen-logo description: 根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入。支持明确输入与结果回读。Use to create a logo and application icon. license: MIT compatibility: 支持 Agent Skills 的宿主;Python 3.8+ 与 PyYAML 用于 Profile 和校验。业务工具要求见工作流。 depends_on: - lov-branding-consistency metadata: author: contributors version: 5.1.2 tags: - logo - app-icon - brand-design - image-generation content_class: microcopy card_standard: lovstudio/skill-card/v1 --- # Logo 工坊 · Logo Studio 迭代生成 Logo,保留每一版原稿,并将“品牌标志”和“应用图标”作为不同产物处理。 ## Triggers ### Activate when - 用户说“生成 Logo”“设计应用图标”“去掉 Logo 白底”或“放大图标主体”。 - 用户要求迭代、选用或发布某一版 Logo。 - The user asks to create, refine, select, or publish a logo or app icon. ### Do not activate when - 用户只想查找已有官方 Logo;交给 Logo 查找类 Skill。 - 用户只想压缩、裁剪或转换普通图片,且不涉及品牌标志设计。 ## 核心原则 - **不覆盖**:每次生成保存为新版本 `v{N}-{描述}`。 - **项目驱动**:根据产品功能、使用场景和品牌气质设计,不按项目名字面联想。 - **分离产物**:透明品牌标志、应用图标和平台派生资源分别制作,禁止相互冒充。 - **视觉占比优先**:用可见图形边界判断大小,不用源文件画布或透明留白判断。 - **真实场景验收**:在 favicon、桌面、Dock、主屏幕等最终使用环境验证视觉重量。 - **确认发布**:用户确认后再写入正式位置;仅在 `--publish` 时提交。 ## 参数 ```text /lov-gen-logo [concept] [--use vN] [--publish vN] ``` - `concept`:设计概念或迭代反馈。 - `--use vN`:选用第 N 版并更新正式资产,不提交。 - `--publish vN`:选用第 N 版、完成平台验证并提交。 ## 产物模型 为每版保留以下文件;按项目实际用途省略不需要的派生项: ```text assets/logo-drafts/ ├── vN-desc-raw.png # 生成器原图,永不覆盖 ├── vN-desc-mark.png # 紧边界透明标志 ├── vN-desc-mark.svg # 矢量标志 ├── vN-desc-app-icon-mark.png # 1024 方形透明布局稿,用于测量主体占比 ├── vN-desc-app-icon.png # 1024 方形、不透明、满铺底色的应用图标 └── vN-desc-preview.png # 小尺寸与明暗背景预览 ``` 区分以下语义: | 产物 | 背景 | 留白 | 用途 | |---|---|---|---| | `mark.png/svg` | 透明 | 紧边界或少量光学留白 | 网站、文档、组合品牌 | | `app-icon-mark.png` | 透明 | 主体长边通常占画布 62%–74% | 测量和合成中间稿 | | `app-icon.png` | 不透明且铺满画布 | 不在外缘留透明或白色边框 | iOS、桌面应用图标 | | Android adaptive icon | 前景与背景分层 | 遵循平台安全区 | Android 启动器 | 除非设计明确要求白色底板,否则不要把白底视为 Logo 的组成部分。iOS 圆角由系统裁切,不要预先烘焙圆角或透明四角。 ## 工作流程 ### Step 0:发现项目与版本 1. 读取 `package.json`、`README.md` 和现有品牌资产。 2. 搜索平台事实源,例如 `app.json`、`Info.plist`、asset catalog、PWA manifest、Electron/Tauri 图标配置。 3. 检查 `assets/logo-drafts/` 的最大版本号,使用下一版本。 4. 记录现有正式 Logo 路径,禁止默认假设一定存在 `assets/` 或 `public/`。 ```bash mkdir -p ./assets/logo-drafts find ./assets/logo-drafts -maxdepth 1 -type f -name 'v[0-9]*-*' \ -print | sort -V | tail -1 ``` ### Step 1:分析项目与设计概念 首次生成时读取: - `package.json` 的名称、描述、关键词和依赖;名称只作标识。 - `README.md` 的核心功能、目标用户和差异化价值。 - 当前界面的形状语言、颜色、圆角、图标密度和相邻品牌资产。 按项目类型选择方向: | 类型 | 识别特征 | 推荐方向 | |---|---|---| | Web 框架/Starter | next、react、vue、angular | 几何抽象、模块、网格 | | CLI 工具 | commander、yargs、`bin` | 光标、尖锐线条、技术感 | | 桌面/移动应用 | electron、tauri、expo、原生工程 | 单一强轮廓、应用图标构图 | | 库/SDK | 无 UI 依赖、纯逻辑 | 极简符号、数学感 | | AI/ML | openai、langchain、模型依赖 | 连接、流动、智能但避免通用星芒 | 迭代版本应先总结上一版的明确问题,例如“主体只占画布 46%,在主屏幕上显小”,再把改进目标写进 Prompt。不要只写“更大”或“更好看”。 ### Step 2:生成原稿 ```bash ${IMAGE_GENERATOR_COMMAND:-image-generator} \ "PROMPT" -o ./assets/logo-drafts/v{N}-{desc}-raw.png -q high ``` Prompt 需要明确: - 只生成独立标志,不含文字、展示样机、边框、卡片和预制圆角底板。 - 使用 2–3 个主要几何形状,在 16×16 仍可辨认。 - 保持轮廓完整、边缘干净、构图居中。 - 生成器不稳定支持透明度时,使用与标志颜色明显不同的纯色背景,后续提取;不要让背景成为设计元素。 - 应用图标的底色、渐变和光效在标志提取后确定性合成。 优先读取 Profile 的 `brand.colors.primary` 或项目已有品牌色;二者都缺少时,先询问本次视觉方向需要的颜色,不在 Skill 内设定某个品牌专属默认色。 ### Step 3:提取透明标志并矢量化 保留 `raw.png`,只对副本去背景。优先从四角连通区域移除背景,避免使用全局 `-transparent white` 误删标志内部的白色区域。 ```bash cd ./assets/logo-drafts magick v{N}-{desc}-raw.png -alpha on \ -bordercolor white -border 1 -fuzz 5% -fill none \ -draw 'alpha 0,0 floodfill' -shave 1x1 \ -trim +repage PNG32:v{N}-{desc}-mark.png magick v{N}-{desc}-mark.png -channel A -threshold 50% +channel temp.png vtracer --input temp.png --output v{N}-{desc}-mark.svg \ --mode spline --filter_speckle 8 --color_precision 8 \ --corner_threshold 120 --segment_length 6 --path_precision 5 npx svgo v{N}-{desc}-mark.svg -o v{N}-{desc}-mark.svg --multipass rm -f temp.png ``` 背景不是白色时,将 `-bordercolor white` 替换为实际角落背景色。提取后放大检查轮廓,不接受白色毛边、半透明光晕、断裂细节或棋盘格伪透明。 ### Step 4:构建应用图标 从透明 `mark.png` 确定性合成应用图标,不直接把带透明留白的 Logo 当作 App Icon。 ```bash $SKILL_DIR/scripts/build_app_icon.sh \ --mark ./assets/logo-drafts/v{N}-{desc}-mark.png \ --output ./assets/logo-drafts/v{N}-{desc}-app-icon.png \ --layout-output ./assets/logo-drafts/v{N}-{desc}-app-icon-mark.png \ --background '#0C150F' \ --occupancy 0.68 ``` 以可见主体长边占画布 `68%` 为起点,根据视觉重量在 `62%–74%` 内调整: - 细线、镂空、尖角或浅色标志取较大值。 - 密实、圆润、高对比标志取较小值。 - 低于 `60%` 通常会在主屏幕上显小;高于 `78%` 通常会显拥挤。 - 比较相邻常见应用图标的视觉重量,不只比较几何宽高。 为渐变背景传入 `--background-image PATH`。保持背景满铺到四角,输出 PNG 必须为 1024×1024、RGB、无 alpha。 Android 项目应另外生成 adaptive icon 的前景和背景层,并在圆形、方形、圆角方形遮罩下预览;不要直接复用 iOS 合成图。 ### Step 5:质量门禁与展示 生成预览并同时检查: - 透明标志置于浅色、深色和棋盘格背景。 - 应用图标以 16、32、60、120、256 像素显示。 - 应用图标与 2–3 个同平台常见图标并排,检查主体视觉重量。 - 背景铺满四角;非刻意白底时不存在白色方块或白边。 - SVG 与 PNG 的形状、方向、颜色一致。 执行基础检查: ```bash magick identify -format '%f %wx%h %[channels]\n' \ v{N}-{desc}-mark.png v{N}-{desc}-app-icon.png magick v{N}-{desc}-app-icon.png \ -format 'corners: %[pixel:p{0,0}] %[pixel:p{1023,0}] %[pixel:p{0,1023}] %[pixel:p{1023,1023}]\n' \ info: ``` 使用图像查看工具展示 `mark.png`、`app-icon.png` 和 `preview.png`,再反馈版本、主体占比、背景语义和改进点。 ```text ✓ v{N}-{desc} 生成完成 主体长边占画布:{ratio}% 产物:透明标志 / 不透明应用图标 / 场景预览 继续迭代可直接描述具体问题;确认后执行 /lov-gen-logo --publish v{N} ``` ### Step 6:选用与发布 执行 `--use` 或 `--publish` 时: 1. 读取平台配置,建立“草稿产物 → 正式事实源”的明确映射。 2. 只写入项目真实存在或配置引用的路径;禁止无条件创建 `assets/logo.*` 和 `public/logo.*`。 3. 运行项目原有的图标生成流程,验证最终打包资源而不只验证源 PNG。 4. Web 项目验证 favicon、PWA manifest 和浅深色背景。 5. 桌面应用验证 Dock、任务栏、菜单栏和安装包图标。 6. 移动应用构建并安装到真实设备,在主屏幕与相邻图标并排验证;安装成功不等于图标验收完成。 7. 用户确认版本后,`--publish` 仅提交本次 Logo 相关文件。 ```bash git status --short git add <本次确认的正式资产> <对应草稿版本> git commit -m "docs: update logo to v{N}-{desc}" ``` ### Step 7:生成 Tray Icon(Tauri) 存在 `src-tauri/icons/` 时,从紧边界透明标志生成模板图标: ```bash magick ./assets/logo-drafts/v{N}-{desc}-mark.png -trim +repage \ -resize 38x38 -gravity center -background transparent -extent 56x44 \ -colorspace gray -fill white -colorize 100% \ src-tauri/icons/tray-icon.png ``` 在 macOS 明暗菜单栏分别检查,不把彩色应用图标直接缩小作为菜单栏模板图标。 ## 依赖 - `python3` 与图像生成后端 - ImageMagick 7(`magick`) - `vtracer` - `svgo` ## 附带脚本 - `scripts/build_app_icon.sh`:按可见主体占比构建 1024×1024 的透明布局稿和不透明应用图标,并校验尺寸与 alpha。 ## Execution boundary 自然语言请求即可触发;无需旧 slash 路径、参数插值或指定助手。明确解析当前请求中的 项目、目标文件、选项与输出位置;用当前宿主实际提供的文件、搜索、CLI 和浏览器能力。 项目依赖版本与外部 API 在执行时核实,不能假设示例是现行配置。随包脚本从 Skill 根解析, 业务文件从目标项目根解析。先读当前状态,保护已有未提交内容与其他任务的暂存区。 分析、预览请求保持只读;修改、提交、推送、部署和发布各依当前请求的明确范围执行。 不绕过保护、自动发送消息、强制结束用户进程或抢前台。失败保留可诊断原始错误。 ## Composition 执行前读取 [能力组合](references/skill-composition.md),按明确制品交接相邻能力。 ## Runtime context (shared) 运行前读取本包 `skill.yaml` 与 [Profile 合同](references/user-profile.md)。优先级为当前请求、 项目上下文、本 Skill records、共享 preferences、brand/user Profile、安全默认值。 只读取声明字段;没有专用运行时的宿主可使用 `scripts/profile_store.py` 读取共享 Profile。 配置缺失只问影响结果的一个问题。用户明确要求长期保存的值通过该脚本原子写入, 报告实际路径;不保存推断、凭据或其他任务的资料。 -
skill.yaml 2.1 KB
schema: skill-manifest/v1 id: lov-gen-logo version: 5.1.2 runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-gen-logo persist: enabled: true namespace: skills.lov-gen-logo records_path: skills.lov-gen-logo.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 如果本次输出需要用户身份,请提供名称。 - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: brand.name aliases: - identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: brand.site required: false question: 如果需要品牌官网,请提供地址。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 - path: identity.name required: true question: 请提供这次要设计 Logo 的品牌或产品名称。 - path: brand.colors required: false question: 请提供需要沿用的品牌色(如有)。 - path: workspace.project_root required: false question: 目标项目路径是什么? - path: workspace.output_dir required: false question: 输出保存在哪里? - path: workspace.knowledge_root required: false question: 本次知识库路径是什么? - path: skills.lov-gen-logo.profile required: false question: 本 Skill 有哪些明确配置? preferences: namespace: lov_gen_logo 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.