Claude Skill

lov-gen-logo

根据项目用途制作版本化 Logo 草稿及应用图标,并按确认范围接入。支持明确输入与结果回读。Use to create a logo and

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

Full trust report

Download lovstudio-skills-skills_gen-logo-0b16007.zip · 24 KB
Part of lovstudio/skills — 83 skills

Install

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

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

README

Logo 工坊 · Logo Studio

Version

迭代式 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:发现项目与版本

  1. 读取 package.json、README.md 和现有品牌资产。
  2. 搜索平台事实源,例如 app.json、Info.plist、asset catalog、PWA manifest、Electron/Tauri 图标配置。
  3. 检查 assets/logo-drafts/ 的最大版本号,使用下一版本。
  4. 记录现有正式 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 时:

  1. 读取平台配置,建立“草稿产物 → 正式事实源”的明确映射。
  2. 只写入项目真实存在或配置引用的路径;禁止无条件创建 assets/logo.* 和 public/logo.*。
  3. 运行项目原有的图标生成流程,验证最终打包资源而不只验证源 PNG。
  4. Web 项目验证 favicon、PWA manifest 和浅深色背景。
  5. 桌面应用验证 Dock、任务栏、菜单栏和安装包图标。
  6. 移动应用构建并安装到真实设备,在主屏幕与相邻图标并排验证;安装成功不等于图标验收完成。
  7. 用户确认版本后,--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)
  • vtracer
  • svgo

附带脚本

  • 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
    
    ![Version](https://img.shields.io/badge/version-5.1.2-blue)
    
    > 迭代式 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.

No comments yet.

Reviews (0)

No reviews yet.

Related