Claude Skill

lov-skill-optimizer

Audit and optimize one or more existing Agent Skills from a canonical source path, then bump semver, update README/SKILL.md/skill.yaml/CHANGELOG.md, and verify installed copies and catalog synchronization. Checks frontmatter, trigger quality, CLI hygiene, naming, portability, ver

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

Full trust report

Download lovstudio-skills-skills_skill-optimizer-77d464c.zip · 26 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/skill-optimizer
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

Skill 精修师 · Skill Refiner

Version

自动审计并优化 Agent Skill:按当前对话优先修复问题,统一 README、SKILL.md、 skill.yaml 与 CHANGELOG 版本,然后核对规范源、安装副本和 catalog 的同步状态。 支持独立 Skill 目录、嵌套仓库和已安装副本,不把未发现的位置当成已同步。 付费 Skill 仓库会审计 src/SKILL.md,同步时只使用 public/ 加密分发载荷,避免把 明文规范误装到用户目录。

用户反馈先分三层:仅当前任务、单个 Skill 可复用、所有 Skill 全局可复用。全局规则只进入 用户级共享规范(例如生效中的 AGENTS.md),不会机械复制到每个 SKILL.md;如果之前放错 了领域 Skill,会迁移到共享层并保留真正的领域规则。任何可复用修改都会使修改前的终稿确认 失效,完成当前产物更新后停下等待下一步。

安装

npx skills add lovstudio/skills --skill lov-skill-optimizer -y -g

Requires: Python 3.8+(仅标准库);Git 用于源码提交/推送核验。

使用

# 审计一个独立 Skill
python3 scripts/lint_skill.py --path /absolute/path/to/skill --json

# 检查规范源、安装副本和 catalog
python3 scripts/inspect_layout.py --path /absolute/path/to/skill --json

# 先比较安装副本,不写入
python3 scripts/sync_installation.py \
  --source /absolute/path/to/canonical-skill \
  --target /absolute/path/to/installed-skill --json

# 审阅后同步普通副本;符号链接只做校验
python3 scripts/sync_installation.py \
  --source /absolute/path/to/canonical-skill \
  --target /absolute/path/to/installed-skill --apply --json

# 统一版本并追加 CHANGELOG
python3 scripts/bump_version.py \
  --path /absolute/path/to/skill \
  --type minor \
  --message "add guarded synchronization audit"

# 审计一个 Skills 根目录
python3 scripts/lint_skill.py --all --root /absolute/path/to/skills --json

也可以使用 Skill 名称(foo、lov-foo、foo-skill);跨仓库或已安装副本优先 使用 --path,以确保修改落在规范源上。

处理顺序

  1. 读取当前对话中的具体问题,区分已认可基线、请求增量和被否决的修复尝试;
  2. 运行 lint,检查 frontmatter、触发语句、可移植性、版本漂移和脚本 CLI;
  3. 只修改规范源;
  4. 统一版本、追加 CHANGELOG;
  5. 重新 lint 并读取布局检查结果;
  6. 比较并同步已发现的安装副本和 catalog;
  7. 精确 staging、提交并推送,逐层报告失败状态。

同一请求中出现多个 Skill 时,按用户给出的顺序逐个处理,每个 Skill 单独 bump 版本并单独输出结果块。

输出状态

报告固定包含 source、distribution、catalog、distribution state、 catalog state 和 sync state。安装副本已同步但 catalog 未发现时,整体仍为 partial;发现 catalog 后还要比较匹配 Skill 的 digest;本地源码提交不等于 catalog 或线上页面已更新。

许可

MIT

Skill manifest

Skill 精修师 · Skill Refiner

This is a non-interactive maintenance workflow. It infers the target and prioritizes issues from the current conversation, then supplements them with a generic lint pass. When several Skills are named in one request, process them in the order named and emit a separate result block for each Skill.

Target and source resolution

Prefer an explicit canonical path whenever the Skill is outside a conventional skills repository:

python3 scripts/lint_skill.py --path /absolute/path/to/skill --json
python3 scripts/inspect_layout.py --path /absolute/path/to/skill --json

For a name, accept foo, lov-foo, or foo-skill. Resolution may find a source checkout, an installed copy, or a catalog entry. Before editing:

  1. Resolve symlinks and record the actual path.
  2. Identify the Git root, branch, and dirty worktree state.
  3. Treat a source checkout as canonical. If the target is an installed copy, locate a matching source checkout; when no source exists, report that the supplied path itself is canonical instead of silently editing another copy.
  4. Record every discovered installation and catalog path. A copy is synced only when its content digest matches the source; a missing location is not_discovered, never complete. A symlink is synced only when it resolves to the canonical source.

Paid/encrypted repositories may keep the authored specification at src/SKILL.md and the installable payload under public/. Treat the repository root as canonical, lint and version the authored spec, and compare or sync only the public/ payload to installations. Never copy plaintext src/ content into an installed paid Skill.

Do not absorb pre-existing edits into a maintenance commit. If a target file is already dirty, review the overlap before editing it and stage only the exact files changed by this optimization.

Workflow (mandatory)

Step 0: Classify feedback scope

When the optimization is triggered by a user correction, classify it before choosing targets:

  • task-specific: applies only to the current artifact or one-off value. Do not edit a Skill.
  • skill-specific reusable: applies to future runs of one domain or platform. Optimize the relevant canonical Skill.
  • global reusable: changes how every Skill should handle feedback, authorization, sequencing, or handoff. Update the host's active user-level shared instruction artifact once (for example the applicable user-level AGENTS.md); do not paste the same policy into every domain SKILL.md.

If a global policy was previously placed in one domain Skill, move it to the shared layer and remove the domain duplicate while preserving any genuinely domain-specific rule learned in the same incident. A shared policy file may be outside the Skill repository; report its path and verification state explicitly instead of pretending it is part of the Skill package.

Any reusable correction invalidates prior terminal approval for the active task. Finish the policy/Skill optimization and validation first, then apply the correction to the current artifact, report the new state, and stop for the user's next instruction. Do not continue into publishing, submission, or another external write using a pre-correction “confirm”, “continue”, or “go ahead”.

Step 1: Extract targets and context

Normalize every explicitly named Skill and preserve the user's order. Strip the lov- prefix only for lookup; keep the public Skill identifier in reports. Collect the current-conversation fix list first: broken flags, trigger misses, wrong paths, confusing output, missing modules, compatibility requirements, or other concrete symptoms.

For every correction, distinguish four pieces of evidence: the observed defect, the latest user-approved baseline, the requested delta, and any remedy the user rejected. A rejected remedy is negative evidence, not a reusable best practice. Preserve the approved baseline outside the requested delta, and do not encode a failed over-correction into the target Skill.

Step 2: Baseline lint

For each target, run the linter against the resolved canonical path:

python3 scripts/lint_skill.py --path /absolute/path/to/skill --json

Prioritize findings in this order:

  1. Fixes explicitly mentioned in the conversation;
  2. error findings;
  3. warn findings;
  4. cheap, low-risk info findings.

The baseline must include version-source drift between README.md, SKILL.md frontmatter, and skill.yaml. Portability findings are high priority for a reusable Skill: move personal paths to flags, environment variables, or references/user-config.md, or mark a genuinely author-only dependency in compatibility.

Classify the Skill's normal output before editing. If it generates, edits, reviews, renders, packages, or publishes text visible to an end user or reader, top-level depends_on must contain lov-branding-consistency. This dependency gates authored presentation copy only; it must not rewrite quotations, transcripts, source data, legal text, identifiers, or code without permission.

Step 3: Apply focused fixes

Edit only the canonical source. Keep the Skill's public trigger surface, compatibility aliases, storage contracts, and user-facing semantics explicit. Use progressive disclosure when SKILL.md grows beyond roughly 500 lines. Add a script or reference file only when it resolves a concrete audit finding or conversation issue.

When the user has already approved an artifact and then requests a localized fix, optimize the domain Skill around a minimal-delta contract: lock accepted layout, copy, data, and behavior as invariants; change only the defective region; verify the whole artifact afterward. A broad redesign requires an explicit new request, not an inferred opportunity.

The linter checks both root SKILL.md repositories and paid/encrypted src/SKILL.md repositories:

  • Agent Skills-compatible frontmatter and trigger phrases;
  • README version badge and installation command;
  • metadata.version, README badge, and skill.yaml version consistency;
  • CLI use of argparse and obvious script hygiene;
  • TODO placeholders and oversized instruction bodies;
  • personal paths, fixed runtime paths, and missing user configuration;
  • source/install/catalog layout evidence.
  • missing lov-branding-consistency dependency for audience-visible text Skills.

Step 4: Bump semver and changelog

Use the path-aware version tool so all version surfaces move together:

python3 scripts/bump_version.py \
  --path /absolute/path/to/skill \
  --type minor \
  --message "add guarded project rename workflow" \
  --change "report source, installation, and catalog synchronization state"

Choose patch for bug, wording, frontmatter, or lint fixes; minor for a new flag, reference, module, or expanded workflow; major for a breaking CLI or removed behavior. Stay in 0.x unless the user explicitly requests otherwise. The tool updates README.md, the authored SKILL.md, paid-skill public wrappers, skill.yaml, and CHANGELOG.md and refuses to duplicate an existing changelog version.

Step 5: Re-lint and inspect layout

python3 scripts/lint_skill.py --path /absolute/path/to/skill --json
python3 scripts/inspect_layout.py --path /absolute/path/to/skill --json

Do not report remaining lint warnings: none unless the final JSON was read. Do not report synchronization as complete unless every discovered distribution copy and required catalog check has been verified after the source change. Keep distribution_state, catalog_state, and the aggregate sync_state separate: an installed copy can be complete while an undiscovered catalog keeps the aggregate state partial. A discovered catalog is complete only when its matching Skill payload digest is synced.

Step 6: Synchronize discovered distributions

inspect_layout.py checks conventional and configured installation roots: AGENT_SKILLS_DIR, CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILLS_DIR, plus the host's agent-managed fallback roots. It also checks explicit --install-root and --catalog-root values plus the nearby unified lovstudio-skills catalog and legacy general/dev catalog names. Use an environment variable or explicit flag when the installation root is outside the conventional layout.

For a non-symlink installation copy, first run a read-only sync plan. Paid repositories automatically use their public/ payload:

python3 scripts/sync_installation.py \
  --source /absolute/path/to/canonical-skill \
  --target /absolute/path/to/installed-skill \
  --json

After reviewing missing, changed, and extra, apply the exact copy with:

python3 scripts/sync_installation.py \
  --source /absolute/path/to/canonical-skill \
  --target /absolute/path/to/installed-skill \
  --apply --json

Use --prune only when removing extra files from the installation copy is explicitly part of the task. Symlink installations are verified, not copied. For a catalog, use its own scripts only when they are actually present:

python3 scripts/sync-skills.py
python3 scripts/render-marketplace.py
python3 scripts/render-readme.py
python3 scripts/validate_deps.py

Run only the commands that exist in that catalog checkout. If no catalog is discovered, report not_discovered; if source and installation are updated but the catalog is stale or unavailable, report partial. Never invent a catalog path or claim a live-site update from a local source commit.

Step 7: Commit and push exact source changes

Inspect git diff --check, then stage the listed changed files explicitly:

git add -- SKILL.md README.md CHANGELOG.md skill.yaml scripts references
git diff --cached --name-only
git commit -m "fix(<skill-name>): <one-line summary>"
git push origin HEAD

Use feat for a minor feature and feat! for a breaking change. If the source checkout has no remote, commit on its current branch and report push: not_configured; do not imply that a remote release happened. If a catalog is a separate repository, commit and push it independently after its own validation.

Final report contract

Return one block per optimized target, with no trailing summary:

optimized: lov-<name>
version:   <old> → <new>
source:    <canonical path> (<clean|dirty>)
distribution:
  - <path>: <synced|drifted|not_discovered>
catalog:
  - <path>: <synced|partial|not_discovered>
fixes:
  - <bullet 1>
  - <bullet 2>
remaining lint warnings: <count>  (or "none")
sync state: <complete|partial|not_discovered>

The keys stay stable for machine parsing; the values and fix bullets follow the user's language. A failed or skipped push, installation sync, or catalog sync must appear in the relevant state rather than being omitted.

Runtime context

Read this Skill's skill.yaml when the host supplies skill-runtime/v1. Use only fields declared there. Profile data is for public identity facts and preferences are for output language/timezone; neither replaces the canonical source, installation, or catalog evidence collected by this workflow.

CLI reference

python3 scripts/lint_skill.py --path PATH [--json]
python3 scripts/lint_skill.py --all --root PATH [--json]
python3 scripts/bump_version.py --path PATH --type patch|minor|major -m MESSAGE
python3 scripts/inspect_layout.py --path PATH [--install-root PATH] [--catalog-root PATH] [--json]
python3 scripts/sync_installation.py --source PATH --target PATH [--apply] [--prune] [--json]

All bundled tools use Python's standard library only.

通用反馈闭环

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

  1. 先判断意见是 task-specific(仅本次)还是 reusable(可跨任务复用)。
  2. task-specific 只修改当前任务,不改 Skill。
  3. reusable 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
  4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
  5. reusable 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
  • scripts
    • bump_version.py 11.4 KB
      #!/usr/bin/env python3
      """
      Bump an Agent Skill's version and append a CHANGELOG entry.
      
      README.md, SKILL.md frontmatter, and skill.yaml are treated as one version set
      and are kept in sync when present. Use --path for a standalone or installed
      skill; no particular repository layout is required.
      
      Usage:
          python bump_version.py <skill-name> --type patch --message "fix frontmatter trigger phrases"
          python bump_version.py <skill-name> --type minor --message "add --verbose flag" --change "add -v shortcut"
          python bump_version.py <skill-name> --set 0.2.0 --message "..."
          python bump_version.py <skill-name> --type patch --message "..." --dry-run
      
      Notes:
      - --type: patch | minor | major (mutually exclusive with --set)
      - --message: single-line summary used as the changelog bullet
      - --change: may be repeated for additional bullet lines
      - Creates CHANGELOG.md if missing (Keep a Changelog format).
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from datetime import date
      from pathlib import Path
      
      BADGE_COLOR = "CC785C"  # LovStudio terracotta
      BADGE_RE = re.compile(
          r"!\[Version\]\(https://img\.shields\.io/badge/version-(\d+\.\d+\.\d+)-[A-Za-z0-9]+\)"
      )
      FM_VERSION_RE = re.compile(r'(?m)^(\s*version:\s*["\']?)(\d+\.\d+\.\d+)(["\']?\s*)$')
      MANIFEST_VERSION_RE = re.compile(r'(?m)^(version:\s*["\']?)(\d+\.\d+\.\d+)(["\']?\s*)$')
      
      
      def canonical_skill_md(skill_dir: Path) -> Path:
          root_spec = skill_dir / "SKILL.md"
          if root_spec.exists():
              return root_spec
          source_spec = skill_dir / "src" / "SKILL.md"
          if source_spec.exists():
              return source_spec
          return root_spec
      
      
      def frontmatter_text(path: Path) -> str:
          if not path.exists():
              return ""
          text = path.read_text(encoding="utf-8", errors="replace")
          if not text.startswith("---"):
              return ""
          end = text.find("\n---", 3)
          return text[: end + 4] if end >= 0 else ""
      
      
      def find_repo_root(start: Path) -> Path:
          for p in [start] + list(start.parents):
              if ((p / "SKILL.md").exists() or (p / "src" / "SKILL.md").exists()) and (p / "skill.yaml").exists():
                  return p
              if (p / "CLAUDE.md").exists() and (p / "skills").is_dir():
                  return p
              if (p / ".git").exists():
                  return p
          return start
      
      
      def resolve_skill_dir(name: str, path: str | None) -> Path:
          if path:
              return Path(path).resolve()
          raw = name
          name = name.removeprefix("lov-")
          if name.endswith("-skill"):
              name = name[: -len("-skill")]
          root = find_repo_root(Path.cwd())
          candidates = [
              root / "skills" / f"lov-{name}",
              root / f"lov-{name}",
              root / f"{name}-skill",
              root / name,
              root / raw,
          ]
          if (root / "SKILL.md").exists():
              candidates.insert(0, root)
          for candidate in candidates:
              if (
                  (candidate / "SKILL.md").exists()
                  or (candidate / "src" / "SKILL.md").exists()
                  or (candidate / "README.md").exists()
              ):
                  return candidate.resolve()
          return candidates[0].resolve()
      
      
      def read_version_sources(skill_dir: Path) -> dict[str, str]:
          versions: dict[str, str] = {}
          readme = skill_dir / "README.md"
          if readme.exists():
              match = BADGE_RE.search(readme.read_text(encoding="utf-8", errors="replace"))
              if match:
                  versions["README.md"] = match.group(1)
      
          skill_md = canonical_skill_md(skill_dir)
          if skill_md.exists():
              rel = skill_md.relative_to(skill_dir).as_posix()
              matches = list(FM_VERSION_RE.finditer(frontmatter_text(skill_md)))
              for index, match in enumerate(matches, start=1):
                  label = rel if len(matches) == 1 else f"{rel}#{index}"
                  versions[label] = match.group(2)
      
          public_skill = skill_dir / "public" / "SKILL.md"
          if public_skill.exists():
              match = FM_VERSION_RE.search(frontmatter_text(public_skill))
              if match:
                  versions["public/SKILL.md"] = match.group(2)
      
          manifest = skill_dir / "skill.yaml"
          if manifest.exists():
              match = MANIFEST_VERSION_RE.search(manifest.read_text(encoding="utf-8", errors="replace"))
              if match:
                  versions["skill.yaml"] = match.group(2)
          return versions
      
      
      def read_current_version(skill_dir: Path) -> str:
          versions = read_version_sources(skill_dir)
          for source in ("README.md", "SKILL.md", "src/SKILL.md", "src/SKILL.md#1", "skill.yaml"):
              if source in versions:
                  return versions[source]
          return "0.0.0"
      
      
      def bump(version: str, kind: str) -> str:
          major, minor, patch = (int(x) for x in version.split("."))
          if kind == "major":
              return f"{major + 1}.0.0"
          if kind == "minor":
              return f"{major}.{minor + 1}.0"
          if kind == "patch":
              return f"{major}.{minor}.{patch + 1}"
          raise ValueError(f"unknown bump type: {kind}")
      
      
      def badge_line(version: str) -> str:
          return f"![Version](https://img.shields.io/badge/version-{version}-{BADGE_COLOR})"
      
      
      def update_readme(skill_dir: Path, new_version: str, dry: bool) -> bool:
          readme = skill_dir / "README.md"
          if not readme.exists():
              return False
          text = readme.read_text(encoding="utf-8")
          new_badge = badge_line(new_version)
          if BADGE_RE.search(text):
              new_text = BADGE_RE.sub(new_badge, text, count=1)
          else:
              # Insert badge right after the H1 title
              lines = text.splitlines()
              inserted = False
              out = []
              for i, line in enumerate(lines):
                  out.append(line)
                  if not inserted and line.startswith("# "):
                      out.append("")
                      out.append(new_badge)
                      inserted = True
              new_text = "\n".join(out)
              if not text.endswith("\n"):
                  new_text += "\n"
          if new_text != text:
              if not dry:
                  readme.write_text(new_text, encoding="utf-8")
              return True
          return False
      
      
      def update_markdown_versions(path: Path, new_version: str, dry: bool) -> bool:
          if not path.exists():
              return False
          text = path.read_text(encoding="utf-8")
          frontmatter = frontmatter_text(path)
          if not frontmatter or not FM_VERSION_RE.search(frontmatter):
              return False
          updated_frontmatter = FM_VERSION_RE.sub(rf'\g<1>{new_version}\g<3>', frontmatter)
          new_text = updated_frontmatter + text[len(frontmatter) :]
          if new_text != text and not dry:
              path.write_text(new_text, encoding="utf-8")
          return new_text != text
      
      
      def update_manifest_version(skill_dir: Path, new_version: str, dry: bool) -> bool:
          manifest = skill_dir / "skill.yaml"
          if not manifest.exists():
              return False
          text = manifest.read_text(encoding="utf-8")
          if not MANIFEST_VERSION_RE.search(text):
              return False
          new_text = MANIFEST_VERSION_RE.sub(rf'\g<1>{new_version}\g<3>', text, count=1)
          if new_text != text and not dry:
              manifest.write_text(new_text, encoding="utf-8")
          return new_text != text
      
      
      CHANGELOG_HEADER = """# Changelog
      
      All notable changes to this skill are documented here.
      Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/)
      
      """
      
      
      def update_changelog(
          skill_dir: Path,
          new_version: str,
          message: str,
          changes: list[str],
          kind: str,
          dry: bool,
      ) -> None:
          path = skill_dir / "CHANGELOG.md"
          today = date.today().isoformat()
          section_title = {"major": "Changed", "minor": "Added", "patch": "Fixed"}.get(kind, "Changed")
      
          entry_lines = [f"## [{new_version}] - {today}", "", f"### {section_title}", "", f"- {message}"]
          for c in changes:
              entry_lines.append(f"- {c}")
          entry_lines.append("")
          entry = "\n".join(entry_lines) + "\n"
      
          if path.exists():
              existing = path.read_text(encoding="utf-8")
              if re.search(rf"^## \[{re.escape(new_version)}\]", existing, re.MULTILINE):
                  raise ValueError(f"CHANGELOG.md already contains version {new_version}")
              if existing.startswith("# Changelog"):
                  # insert after header paragraph
                  head_end = existing.find("\n## ")
                  if head_end == -1:
                      # no prior entries
                      body = existing.rstrip() + "\n\n" + entry
                  else:
                      body = existing[:head_end].rstrip() + "\n\n" + entry + existing[head_end + 1 :]
              else:
                  body = CHANGELOG_HEADER + entry + existing
          else:
              body = CHANGELOG_HEADER + entry
      
          if not dry:
              path.write_text(body, encoding="utf-8")
      
      
      def main():
          ap = argparse.ArgumentParser(description="Bump version and update changelog for an Agent Skill")
          ap.add_argument("name", nargs="?", help="Skill name (with or without lov- prefix)")
          ap.add_argument("--path", help="Path to skill directory (overrides name; relative paths are resolved)")
          group = ap.add_mutually_exclusive_group(required=True)
          group.add_argument("--type", choices=["patch", "minor", "major"], help="Semver bump kind")
          group.add_argument("--set", dest="set_version", help="Set an explicit version (e.g. 0.2.0)")
          ap.add_argument("--message", "-m", required=True, help="Primary changelog entry")
          ap.add_argument("--change", "-c", action="append", default=[], help="Additional changelog bullets")
          ap.add_argument("--dry-run", action="store_true", help="Show what would change without writing")
          ap.add_argument("--json", action="store_true", help="Emit a machine-readable result")
          args = ap.parse_args()
      
          if not args.name and not args.path:
              ap.error("provide a skill name or --path")
      
          skill_dir = resolve_skill_dir(args.name or "", args.path)
          if not skill_dir.exists():
              print(f"ERROR: skill directory not found: {skill_dir}", file=sys.stderr)
              sys.exit(1)
      
          current = read_current_version(skill_dir)
          versions_before = read_version_sources(skill_dir)
          if args.set_version:
              if not re.match(r"^\d+\.\d+\.\d+$", args.set_version):
                  print(f"ERROR: --set value must be semver x.y.z (got {args.set_version})", file=sys.stderr)
                  sys.exit(1)
              new_version = args.set_version
              kind = "minor"  # default section label for manual sets
          else:
              new_version = bump(current, args.type)
              kind = args.type
      
          print(f"skill:    {skill_dir.name}")
          print(f"current:  {current}")
          print(f"new:      {new_version}")
          print(f"message:  {args.message}")
          if args.dry_run:
              print("(dry run — no files written)")
      
          changed = []
          if update_readme(skill_dir, new_version, args.dry_run):
              changed.append("README.md")
          skill_md = canonical_skill_md(skill_dir)
          if update_markdown_versions(skill_md, new_version, args.dry_run):
              changed.append(skill_md.relative_to(skill_dir).as_posix())
          public_skill = skill_dir / "public" / "SKILL.md"
          if update_markdown_versions(public_skill, new_version, args.dry_run):
              changed.append("public/SKILL.md")
          if update_manifest_version(skill_dir, new_version, args.dry_run):
              changed.append("skill.yaml")
          update_changelog(skill_dir, new_version, args.message, args.change, kind, args.dry_run)
          changed.append("CHANGELOG.md")
      
          result = {
              "skill": skill_dir.name,
              "path": str(skill_dir),
              "current": current,
              "new": new_version,
              "changed": changed,
              "dry_run": args.dry_run,
              "versions_before": versions_before,
          }
          if args.json:
              print(json.dumps(result, ensure_ascii=False, indent=2))
          else:
              print(f"changed:  {', '.join(changed)}")
              print("done.")
      
      
      if __name__ == "__main__":
          main()
      
    • inspect_layout.py 9.2 KB
      #!/usr/bin/env python3
      """Inspect a Skill's source, installed copies, and nearby catalog checkouts.
      
      This command is read-only. It reports what was discovered and never treats a
      missing catalog or a drifted installation as synchronized.
      """
      
      from __future__ import annotations
      
      import argparse
      import hashlib
      import json
      import os
      import subprocess
      import sys
      from pathlib import Path
      
      
      IGNORED_DIRS = {".git", ".worktrees", "node_modules", "dist", "build", "target", ".venv", "__pycache__"}
      INSTALL_ENV_VARS = ("AGENT_SKILLS_DIR", "CLAUDE_SKILLS_DIR", "CODEX_SKILLS_DIR", "SKILLS_DIR")
      
      
      def run_git(directory: Path, *args: str) -> str | None:
          try:
              result = subprocess.run(
                  ["git", "-C", str(directory), *args],
                  check=True,
                  capture_output=True,
                  text=True,
              )
          except (OSError, subprocess.CalledProcessError):
              return None
          return result.stdout.strip()
      
      
      def skill_short_name(skill_dir: Path) -> str:
          name = skill_dir.name
          if name.endswith("-skill"):
              name = name[: -len("-skill")]
          return name.removeprefix("lov-")
      
      
      def tree_digest(directory: Path) -> str | None:
          if not directory.is_dir():
              return None
          digest = hashlib.sha256()
          files = []
          for path in directory.rglob("*"):
              if not path.is_file() or any(part in IGNORED_DIRS for part in path.relative_to(directory).parts):
                  continue
              files.append(path)
          for path in sorted(files):
              relative = path.relative_to(directory).as_posix().encode("utf-8")
              digest.update(relative)
              digest.update(b"\0")
              try:
                  digest.update(path.read_bytes())
              except OSError:
                  return None
              digest.update(b"\0")
          return digest.hexdigest()
      
      
      def distribution_payload(source: Path) -> Path:
          """Use the publishable payload for paid/encrypted source repositories."""
          public = source / "public"
          if (source / "src" / "SKILL.md").exists() and (public / "SKILL.md").exists():
              return public
          return source
      
      
      def split_paths(values: list[str]) -> list[Path]:
          paths: list[Path] = []
          for value in values:
              for item in value.split(os.pathsep):
                  if item.strip():
                      paths.append(Path(item).expanduser().resolve())
          return paths
      
      
      def installation_candidates(source: Path, explicit_roots: list[str]) -> list[Path]:
          roots = split_paths(explicit_roots)
          for variable in INSTALL_ENV_VARS:
              value = os.environ.get(variable)
              if value:
                  roots.extend(split_paths([value]))
          roots.extend(
              path
              for path in (Path.home() / ".agents" / "skills", Path.home() / ".codex" / "skills")
              if path.is_dir()
          )
          names = [f"lov-{skill_short_name(source)}", source.name, skill_short_name(source)]
          result: list[Path] = []
          for root in dict.fromkeys(roots):
              for name in names:
                  candidate = root / name
                  if candidate.is_dir() and candidate != source:
                      result.append(candidate)
          return list(dict.fromkeys(result))
      
      
      def catalog_candidates(source: Path, explicit_roots: list[str]) -> list[Path]:
          roots = split_paths(explicit_roots)
          environment = os.environ.get("LOV_SKILL_CATALOG_ROOT")
          if environment:
              roots.extend(split_paths([environment]))
          git_root = run_git(source, "rev-parse", "--show-toplevel")
          anchors = [source.parent, source.parent.parent]
          if git_root:
              git_parent = Path(git_root).parent
              anchors.extend((git_parent, git_parent.parent))
          for anchor in anchors:
              for name in (
                  "lovstudio-skills",
                  "lovstudio-general-skills",
                  "lovstudio-dev-skills",
                  "general-skills",
                  "dev-skills",
              ):
                  candidate = anchor / name
                  if candidate.is_dir():
                      roots.append(candidate)
          return list(dict.fromkeys(path for path in roots if path.is_dir()))
      
      
      def catalog_state(directory: Path, short_name: str, source_digest: str | None) -> dict:
          manifest = directory / "skills.yaml"
          sync_scripts = sorted(
              path.relative_to(directory).as_posix()
              for path in directory.rglob("sync-skills.py")
              if not any(part in IGNORED_DIRS for part in path.relative_to(directory).parts)
          )
          matching = []
          for path in directory.rglob("SKILL.md"):
              if any(part in IGNORED_DIRS for part in path.relative_to(directory).parts):
                  continue
              if skill_short_name(path.parent) == short_name:
                  digest = tree_digest(path.parent)
                  matching.append(
                      {
                          "path": path.parent.relative_to(directory).as_posix(),
                          "digest": digest,
                          "state": "synced" if digest == source_digest else "drifted",
                      }
                  )
          state = (
              "synced"
              if matching and all(item["state"] == "synced" for item in matching)
              else "drifted"
              if matching
              else "not_found"
          )
          return {
              "path": str(directory),
              "manifest": str(manifest) if manifest.exists() else None,
              "sync_scripts": sync_scripts,
              "matching_skills": matching,
              "state": state,
          }
      
      
      def inspect(source: Path, install_roots: list[str], catalog_roots: list[str]) -> dict:
          source = source.expanduser().resolve()
          if not source.is_dir():
              raise FileNotFoundError(source)
          short_name = skill_short_name(source)
          source_digest = tree_digest(source)
          payload = distribution_payload(source)
          payload_digest = tree_digest(payload)
          installations = []
          for candidate in installation_candidates(source, install_roots):
              resolves_to_payload = candidate.is_symlink() and candidate.resolve() in {source, payload}
              digest = payload_digest if resolves_to_payload else tree_digest(candidate)
              installations.append(
                  {
                      "path": str(candidate),
                      "kind": "symlink" if candidate.is_symlink() else "copy",
                      "digest": digest,
                      "state": "synced" if digest == payload_digest else "drifted",
                  }
              )
          git_root = run_git(source, "rev-parse", "--show-toplevel")
          status = run_git(source, "status", "--porcelain=v1", "--untracked-files=all")
          branch = run_git(source, "branch", "--show-current")
          catalogs = [catalog_state(path, short_name, payload_digest) for path in catalog_candidates(source, catalog_roots)]
          distribution_state = (
              "complete"
              if installations and all(item["state"] == "synced" for item in installations)
              else "partial" if installations else "not_discovered"
          )
          catalog_state_value = (
              "complete"
              if catalogs and all(item["state"] == "synced" for item in catalogs)
              else "partial"
              if catalogs
              else "not_discovered"
          )
          sync_state = (
              "complete"
              if distribution_state == "complete" and catalog_state_value == "complete"
              else "partial"
              if installations or catalogs
              else "not_discovered"
          )
          return {
              "source": {
                  "path": str(source),
                  "digest": source_digest,
                  "git_root": git_root,
                  "branch": branch,
                  "worktree": "dirty" if status else "clean",
                  "status_lines": len(status.splitlines()) if status else 0,
              },
              "payload": {
                  "path": str(payload),
                  "digest": payload_digest,
                  "kind": "public" if payload != source else "source",
              },
              "installations": installations,
              "catalogs": catalogs,
              "distribution_state": distribution_state,
              "catalog_state": catalog_state_value,
              "sync_state": sync_state,
          }
      
      
      def main() -> None:
          parser = argparse.ArgumentParser(description="Inspect Skill source, installations, and catalog locations")
          parser.add_argument("--path", required=True, help="Path to the canonical Skill directory")
          parser.add_argument("--install-root", action="append", default=[], help="Additional installation root; repeatable")
          parser.add_argument("--catalog-root", action="append", default=[], help="Additional catalog root; repeatable")
          parser.add_argument("--json", action="store_true", help="Emit JSON")
          args = parser.parse_args()
          try:
              result = inspect(Path(args.path), args.install_root, args.catalog_root)
          except FileNotFoundError as error:
              print(f"ERROR: skill directory not found: {error}", file=sys.stderr)
              raise SystemExit(1) from error
          if args.json:
              print(json.dumps(result, ensure_ascii=False, indent=2))
              return
          source = result["source"]
          print(f"source: {source['path']} ({source['worktree']}, branch={source['branch'] or 'detached'})")
          for installation in result["installations"]:
              print(f"installation: {installation['path']} [{installation['state']}; {installation['kind']}]")
          for catalog in result["catalogs"]:
              print(f"catalog: {catalog['path']} [{catalog['state']}; sync scripts={len(catalog['sync_scripts'])}]")
          print(f"distribution state: {result['distribution_state']}")
          print(f"catalog state: {result['catalog_state']}")
          print(f"sync state: {result['sync_state']}")
      
      
      if __name__ == "__main__":
          main()
      
    • lint_skill.py 29.7 KB
      #!/usr/bin/env python3
      """
      Audit a LovStudio/Agent Skill against repo conventions and portability rules.
      
      Usage:
          python lint_skill.py <skill-name>           # e.g. any2pdf or lov-any2pdf
          python lint_skill.py <skill-name> --json
          python lint_skill.py --path /abs/path/to/skills/lov-any2pdf
      
      The linter is deliberately dependency-free and works with a standalone skill
      directory, an installed copy, or a skill nested in a catalog repository.
      Outputs findings with severity (error/warn/info) and a `fix_hint` field.
      """
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import sys
      from pathlib import Path
      
      SEVERITIES = ("error", "warn", "info")
      
      FRONTMATTER_REQUIRED = ["name", "description", "license", "compatibility", "metadata"]
      METADATA_REQUIRED = ["author", "version", "tags"]
      STANDARD_NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
      SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
      AUDIENCE_ACTION_RE = re.compile(r"\b(create|generate|write|edit|render|publish|package)\b|创建|生成|撰写|编辑|渲染|发布|排版", re.I)
      AUDIENCE_ARTIFACT_RE = re.compile(r"\b(poster|article|document|pdf|deck|copy|proposal|report|flyer)\b|海报|文章|文案|文档|报告|策划案", re.I)
      USER_CONFIG_CUES = (
          "## User Configuration",
          "## 用户配置",
          "user-config.md",
          "SKILL_PROFILE_PATH",
          "SKILLS_CONFIG_DIR",
          "SKILL_WORKSPACE_ROOT",
          "SKILL_OUTPUT_DIR",
          "SKILL_PROFILE_PATH",
          "SKILL_DESIGN_GUIDE",
          "SKILL_MAINTAIN_PARTNERS_SITE_ROOT",
          "SKILL_MAINTAIN_PARTNERS_FILE",
          "AGENT_SKILLS_DIR",
          "CLAUDE_SKILLS_DIR",
          "CODEX_SKILLS_DIR",
          "SKILLS_DIR",
          "LOV_SKILL_CATALOG_ROOT",
          # Legacy cues accepted during migration.
          "AGENT_SKILL_PROFILE",
          "SKILL_SKILL_PROFILE",
          "PARTNERS_SITE_ROOT",
          "PARTNERS_FILE",
      )
      USER_PATH_PATTERN = r"/" + r"Users" + r"/[^/\s]+(?:/|\b)"
      LOCAL_PATH_PATTERNS = (
          ("LOCAL_USER_PATH", re.compile(USER_PATH_PATTERN), "user home path"),
          ("LOCAL_SKILL_PATH", re.compile(r"(?<![A-Za-z0-9_])~/?skill-publisher(?:/|\b)|\$HOME/skill-publisher(?:/|\b)"), "~/skill-publisher"),
          ("LOCAL_CLAUDE_PATH", re.compile(r"~/\.claude|/\.claude/skills|\$HOME/\.claude"), "~/.claude"),
          ("LOCAL_AGENTS_PATH", re.compile(r"~/\.agents|/\.agents/skills|\$HOME/\.agents"), "~/.agents"),
          ("CLIENT_PLUGIN_ENV", re.compile(r"CLAUDE_PLUGIN_ROOT"), "CLAUDE_PLUGIN_ROOT"),
      )
      NEGATIVE_LOCAL_PATH_CONTEXT_RE = re.compile(
          r"must not assume|do not hard-?code|not require|should not require|avoid hard-?coded|"
          r"without assuming|move user-specific paths",
          re.I,
      )
      
      
      def has_required_local_path(text: str, pattern: re.Pattern) -> bool:
          for line in text.splitlines():
              if not pattern.search(line):
                  continue
              if NEGATIVE_LOCAL_PATH_CONTEXT_RE.search(line):
                  continue
              return True
          return False
      
      
      def looks_like_skills_root(path: Path) -> bool:
          if not path.exists() or not path.is_dir():
              return False
          try:
              for child in path.iterdir():
                  if child.is_dir() and ((child / "SKILL.md").exists() or (child / "src" / "SKILL.md").exists()):
                      return True
                  if child.is_dir() and child.name.endswith("-skill") and (
                      (child / "SKILL.md").exists() or (child / "src" / "SKILL.md").exists()
                  ):
                      return True
          except OSError:
              return False
          return False
      
      
      def find_repo_root(start: Path) -> Path:
          for p in [start] + list(start.parents):
              if looks_like_skills_root(p):
                  return p
              if (p / "skills.yaml").exists() and (p / "skills").is_dir():
                  return p
              if (p / "CLAUDE.md").exists() and (p / "skills").is_dir():
                  return p
          return start
      
      
      def resolve_skill_dir(name: str, path: str | None) -> Path:
          if path:
              return Path(path).resolve()
          raw = name
          name = name.removeprefix("lov-")
          if name.endswith("-skill"):
              name = name[: -len("-skill")]
          root = find_repo_root(Path.cwd())
          candidates = [
              root / f"{name}-skill",
              root / f"lov-{name}",
              root / name,
              root / "skills" / f"lov-{name}",
              root / "skills" / name,
              root / "skills" / f"{name}-skill",
              root / raw,
          ]
          for c in candidates:
              if (c / "SKILL.md").exists() or (c / "src" / "SKILL.md").exists():
                  return c.resolve()
          return candidates[0].resolve()
      
      
      def discover_skill_dirs(root: Path) -> list[Path]:
          dirs = []
          ignored = {".git", ".worktrees", "node_modules", "dist", "build", "target", ".venv", "__pycache__"}
          for skill_md in root.rglob("SKILL.md"):
              parts = set(skill_md.parts)
              if parts.intersection(ignored):
                  continue
              if skill_md.parent.name in {"src", "public"} and (skill_md.parent.parent / "skill.yaml").exists():
                  dirs.append(skill_md.parent.parent)
              else:
                  dirs.append(skill_md.parent)
          if (root / "SKILL.md").exists():
              dirs.append(root)
          return sorted(set(dirs))
      
      
      def canonical_skill_md(skill_dir: Path) -> Path:
          """Return the authored Skill spec, including paid/encrypted repo layouts."""
          root_spec = skill_dir / "SKILL.md"
          if root_spec.exists():
              return root_spec
          source_spec = skill_dir / "src" / "SKILL.md"
          if source_spec.exists():
              return source_spec
          return root_spec
      
      
      def read_manifest_fields(path: Path) -> dict[str, str]:
          """Read the small top-level scalar subset needed from skill.yaml.
      
          A full YAML dependency would make the maintenance skill harder to install.
          Nested metadata is already represented in SKILL.md, so the manifest audit
          intentionally checks only top-level id/version values.
          """
          if not path.exists():
              return {}
          fields: dict[str, str] = {}
          for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
              match = re.match(r"^(id|version):\s*(.+?)\s*$", line)
              if not match:
                  continue
              fields[match.group(1)] = match.group(2).strip().strip('"').strip("'")
          return fields
      
      
      def parse_frontmatter(text: str) -> tuple[dict, str]:
          """Naive YAML frontmatter parser — enough for lint purposes."""
          if not text.startswith("---"):
              return {}, text
          end = text.find("\n---", 3)
          if end == -1:
              return {}, text
          fm_block = text[3:end].strip("\n")
          body = text[end + 4 :].lstrip("\n")
          data: dict = {}
          current_key = None
          buf: list[str] = []
          for line in fm_block.splitlines():
              if not line.strip():
                  continue
              m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", line)
              if m and not line.startswith((" ", "\t")):
                  if current_key is not None:
                      data[current_key] = "\n".join(buf).strip() if buf else data.get(current_key, "")
                  current_key = m.group(1)
                  val = m.group(2).strip()
                  buf = []
                  if val in (">", "|", ">-", "|-"):
                      data[current_key] = ""
                  elif val == "":
                      data[current_key] = {}  # likely nested
                  else:
                      data[current_key] = val.strip('"').strip("'")
                      current_key = None
              elif current_key and line.startswith((" ", "\t")):
                  stripped = line.strip()
                  # nested key under metadata
                  m2 = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", stripped)
                  if m2 and isinstance(data.get(current_key), dict):
                      data[current_key][m2.group(1)] = m2.group(2).strip().strip('"').strip("'")
                  else:
                      buf.append(stripped)
          if current_key is not None and buf:
              if isinstance(data.get(current_key), str) and data[current_key] == "":
                  data[current_key] = "\n".join(buf).strip()
          return data, body
      
      
      class Linter:
          def __init__(self, skill_dir: Path):
              self.dir = skill_dir
              dir_name = skill_dir.name
              short = dir_name
              if short.endswith("-skill"):
                  short = short[: -len("-skill")]
              short = short.removeprefix("lov-")
              self.name = short
              self.expected_standard_name = f"lov-{short}"
              self.findings: list[dict] = []
      
          def add(self, severity: str, code: str, message: str, fix_hint: str = "", file: str = ""):
              self.findings.append(
                  {
                      "severity": severity,
                      "code": code,
                      "message": message,
                      "fix_hint": fix_hint,
                      "file": file,
                  }
              )
      
          # --- Checks ---
      
          def check_structure(self):
              if not self.dir.exists():
                  self.add("error", "DIR_MISSING", f"Skill directory not found: {self.dir}")
                  return
              source_dir_ok = self.dir.name == self.name or self.dir.name == f"{self.name}-skill" or self.dir.name == self.expected_standard_name
              if not source_dir_ok or not STANDARD_NAME_RE.match(self.dir.name.removesuffix("-skill")):
                  self.add(
                      "warn",
                      "DIR_NONSTANDARD",
                      f"Directory '{self.dir.name}' is not in a recognized source/install format",
                      "Use <name>-skill for source repos or lov-<name> for installed/distributed dirs",
                  )
              required_files = (("SKILL.md", canonical_skill_md(self.dir)), ("README.md", self.dir / "README.md"))
              for required, f in required_files:
                  if not f.exists():
                      self.add(
                          "error",
                          f"MISSING_{required.split('.')[0]}",
                          f"{required} is missing",
                          f"Create {required} using the templates in skill-creator references",
                          file=required,
                      )
      
          def check_skill_md(self):
              path = canonical_skill_md(self.dir)
              if not path.exists():
                  return
              rel = path.relative_to(self.dir).as_posix()
              text = path.read_text(encoding="utf-8")
              fm, body = parse_frontmatter(text)
              frontmatter = text[: text.find("\n---", 3) + 4] if text.startswith("---") and text.find("\n---", 3) >= 0 else ""
      
              for key in FRONTMATTER_REQUIRED:
                  if key not in fm:
                      self.add(
                          "error",
                          "FM_MISSING_FIELD",
                          f"SKILL.md frontmatter missing required field '{key}'",
                          f"Add '{key}:' to frontmatter",
                          file=rel,
                      )
      
              name = fm.get("name", "")
              if name and not STANDARD_NAME_RE.match(name):
                  self.add(
                      "error",
                      "FM_NAME_INVALID",
                      f"frontmatter name '{name}' is not Agent Skills-compatible",
                      f"Use name: {self.expected_standard_name}",
                      file=rel,
                  )
              elif name and name != self.expected_standard_name:
                  self.add(
                      "warn",
                      "FM_NAME_MISMATCH",
                      f"frontmatter name '{name}' does not match standard expected name '{self.expected_standard_name}'",
                      f"Set name: {self.expected_standard_name} or document why this is author-only",
                      file=rel,
                  )
      
              desc = fm.get("description", "") or ""
              if isinstance(desc, str):
                  if len(desc) < 80:
                      self.add(
                          "warn",
                          "FM_DESC_TOO_SHORT",
                          "description is shorter than 80 chars — likely missing trigger info",
                          "Expand description to cover: what it does + when to trigger + specific user phrases",
                          file=rel,
                      )
                  if "trigger" not in desc.lower() and "mention" not in desc.lower() and "use when" not in desc.lower():
                      self.add(
                          "warn",
                          "FM_DESC_NO_TRIGGER",
                          "description lacks explicit trigger cues (e.g. 'Use when...', 'trigger when user mentions...')",
                          "Add 'Use when ...' and 'Also trigger when the user mentions \"...\"' phrases",
                          file=rel,
                      )
      
              meta = fm.get("metadata")
              if isinstance(meta, dict):
                  for k in METADATA_REQUIRED:
                      if k not in meta:
                          self.add(
                              "warn",
                              "FM_META_FIELD",
                              f"metadata.{k} missing",
                              f"Add metadata.{k}",
                              file=rel,
                          )
                  version = meta.get("version", "")
                  if version and not SEMVER_RE.match(version):
                      self.add(
                          "warn",
                          "FM_VERSION_FORMAT",
                          f"metadata.version '{version}' is not semver x.y.z",
                          "Use semver format like 0.1.0",
                          file=rel,
                      )
      
              self.check_version_sources(fm, rel)
      
              audience_sample = f"{fm.get('description', '')}\n{body[:4000]}"
              audience_visible = AUDIENCE_ACTION_RE.search(audience_sample) and AUDIENCE_ARTIFACT_RE.search(audience_sample)
              if audience_visible and "lov-branding-consistency" not in frontmatter:
                  self.add(
                      "warn",
                      "MISSING_BRANDING_DEP",
                      "audience-visible authored output lacks the lov-branding-consistency dependency",
                      "Add lov-branding-consistency to top-level depends_on; preserve quoted/source text verbatim",
                      file=rel,
                  )
      
              # Body checks
              if re.search(r"TODO:\s", body) or "TODO_CN" in body or "TODO_EN" in body:
                  self.add(
                      "error",
                      "BODY_TODO",
                      "SKILL.md body still contains TODO placeholders",
                      "Replace all TODOs with real content",
                      file=rel,
                  )
              non_interactive = any(
                  phrase in body.lower()
                  for phrase in ("fully automatic", "no interactive", "non-interactive", "do not ask the user")
              )
              if (
                  "AskUserQuestion" not in body
                  and "## Workflow" in body
                  and not non_interactive
              ):
                  self.add(
                      "info",
                      "BODY_NO_ASKUSER",
                      "Workflow does not mention AskUserQuestion — interactive skills should collect options before running",
                      "Add an 'Ask the user' step using AskUserQuestion",
                      file=rel,
                  )
              if len(body.splitlines()) > 500:
                  self.add(
                      "warn",
                      "BODY_TOO_LONG",
                      "SKILL.md body exceeds 500 lines — consider progressive disclosure",
                      "Split long sections to references/ and link from SKILL.md",
                      file=rel,
                  )
      
          def check_version_sources(self, fm: dict, skill_rel: str):
              """Ensure README, SKILL.md and skill.yaml do not advertise different versions."""
              versions: dict[str, str] = {}
              readme = self.dir / "README.md"
              if readme.exists():
                  match = re.search(
                      r"!\[Version\]\(https://img\.shields\.io/badge/version-(\d+\.\d+\.\d+)-[A-Za-z0-9]+\)",
                      readme.read_text(encoding="utf-8", errors="replace"),
                  )
                  if match:
                      versions["README.md"] = match.group(1)
      
              if fm.get("version"):
                  versions[f"{skill_rel} version"] = str(fm["version"])
      
              metadata = fm.get("metadata")
              if isinstance(metadata, dict) and metadata.get("version"):
                  versions[f"{skill_rel} metadata.version"] = str(metadata["version"])
      
              public_spec = self.dir / "public" / "SKILL.md"
              if public_spec.exists():
                  public_fm, _ = parse_frontmatter(public_spec.read_text(encoding="utf-8", errors="replace"))
                  if public_fm.get("version"):
                      versions["public/SKILL.md version"] = str(public_fm["version"])
      
              encrypted_versions: list[tuple[str, str]] = []
              for manifest_rel in ("public/MANIFEST.enc.json", "dist/MANIFEST.enc.json"):
                  encrypted_manifest = self.dir / manifest_rel
                  if not encrypted_manifest.exists():
                      continue
                  try:
                      encrypted_data = json.loads(encrypted_manifest.read_text(encoding="utf-8"))
                  except (OSError, json.JSONDecodeError):
                      self.add(
                          "error",
                          "ENC_MANIFEST_INVALID",
                          f"{manifest_rel} is not valid JSON",
                          "Regenerate the encrypted distribution bundle",
                          file=manifest_rel,
                      )
                      continue
                  if encrypted_data.get("skill_version"):
                      encrypted_versions.append((manifest_rel, str(encrypted_data["skill_version"])))
      
              manifest = read_manifest_fields(self.dir / "skill.yaml")
              if manifest.get("version"):
                  versions["skill.yaml"] = manifest["version"]
              for source, version in versions.items():
                  if not SEMVER_RE.match(version):
                      self.add(
                          "warn",
                          "VERSION_INVALID",
                          f"{source} advertises non-semver version '{version}'",
                          "Use semver format x.y.z",
                          file=source,
                      )
              unique = set(versions.values())
              if len(unique) > 1:
                  detail = ", ".join(f"{source}={version}" for source, version in versions.items())
                  self.add(
                      "error",
                      "VERSION_DRIFT",
                      f"version sources are inconsistent: {detail}",
                      "Run bump_version.py with --path so README.md, SKILL.md and skill.yaml are updated together",
                      file="README.md/SKILL.md/skill.yaml",
                  )
      
              canonical_version = manifest.get("version") or next(iter(versions.values()), "")
              for manifest_rel, encrypted_version in encrypted_versions:
                  if canonical_version and encrypted_version != canonical_version:
                      self.add(
                          "warn",
                          "ENC_BUNDLE_STALE",
                          f"{manifest_rel} is {encrypted_version}, canonical source is {canonical_version}",
                          "Rebuild and publish the encrypted bundle before reporting distribution sync",
                          file=manifest_rel,
                      )
      
              manifest_id = manifest.get("id")
              skill_name = str(fm.get("name", ""))
              if manifest_id and skill_name and manifest_id != skill_name:
                  self.add(
                      "warn",
                      "MANIFEST_ID_DRIFT",
                      f"skill.yaml id '{manifest_id}' does not match SKILL.md name '{skill_name}'",
                      "Keep the runtime manifest id and Agent Skills name in sync",
                      file="skill.yaml",
                  )
      
          def check_portability(self):
              files = []
              spec = canonical_skill_md(self.dir)
              candidates = ((spec.relative_to(self.dir).as_posix(), spec), ("README.md", self.dir / "README.md"))
              for rel, path in candidates:
                  if path.exists():
                      files.append((rel, path))
              scripts_dir = self.dir / "scripts"
              if scripts_dir.exists():
                  for path in scripts_dir.glob("*.py"):
                      files.append((f"scripts/{path.name}", path))
      
              combined = "\n".join(
                  path.read_text(encoding="utf-8", errors="replace")
                  for _, path in files
              )
              has_user_config = any(cue in combined for cue in USER_CONFIG_CUES)
              skill_md = canonical_skill_md(self.dir)
              compatibility = ""
              if skill_md.exists():
                  fm, _ = parse_frontmatter(skill_md.read_text(encoding="utf-8", errors="replace"))
                  compatibility = str(fm.get("compatibility", ""))
              author_only = bool(
                  re.search(
                      r"author-only|internal only|LovStudio internal|Mark/LovStudio private",
                      compatibility,
                      re.I,
                  )
              )
      
              for rel, path in files:
                  text = path.read_text(encoding="utf-8", errors="replace")
                  if "LOCAL_PATH_PATTERNS" in text and "def check_portability" in text:
                      continue
                  for code, pattern, label in LOCAL_PATH_PATTERNS:
                      if not has_required_local_path(text, pattern):
                          continue
                      if code == "LOCAL_CLAUDE_PATH" and rel == "README.md" and "git clone" in text:
                          severity = "info"
                          hint = "Install examples may mention ~/.claude, but runtime examples should use SKILL_DIR or CLAUDE_SKILLS_DIR"
                      elif author_only:
                          severity = "info"
                          hint = "Author-only skill: keep local paths centralized in one configuration section"
                      elif has_user_config:
                          severity = "info"
                          hint = "User config exists; verify this path is only a fallback/example and not required"
                      else:
                          severity = "warn"
                          hint = (
                              "Move user-specific paths to CLI flags, env vars, or "
                              "references/user-config.md; do not require this local path"
                          )
                      self.add(
                          severity,
                          code,
                          f"{rel} references local/client-specific path '{label}'",
                          hint,
                          file=rel,
                      )
      
          def check_readme(self):
              path = self.dir / "README.md"
              if not path.exists():
                  return
              text = path.read_text(encoding="utf-8")
              if re.search(r"TODO:\s", text) or "pip install TODO" in text or "--output TODO" in text:
                  self.add(
                      "error",
                      "README_TODO",
                      "README.md contains TODO placeholders",
                      "Fill in install, usage, options",
                      file="README.md",
                  )
              if not re.search(r"!\[Version\]\(https://img\.shields\.io/badge/version-", text):
                  self.add(
                      "warn",
                      "README_NO_BADGE",
                      "README.md missing version badge",
                      "Add ![Version](https://img.shields.io/badge/version-X.Y.Z-CC785C) near the top",
                      file="README.md",
                  )
              has_install = any(
                  token in text
                  for token in (
                      "npx lovstudio skills add",
                      "npx skills add",
                      "git clone https://github.com/lovstudio",
                      "/plugin install",
                  )
              )
              if not has_install:
                  self.add(
                      "warn",
                      "README_NO_INSTALL",
                      "README.md missing install command",
                      "Add an install block using npx skills add or git clone into ${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}/lov-<name>",
                      file="README.md",
                  )
      
          def check_changelog(self):
              path = self.dir / "CHANGELOG.md"
              if not path.exists():
                  self.add(
                      "warn",
                      "NO_CHANGELOG",
                      "CHANGELOG.md not found",
                      "Run bump_version.py to create an initial entry",
                      file="CHANGELOG.md",
                  )
      
          def check_scripts(self):
              scripts_dir = self.dir / "scripts"
              if not scripts_dir.exists():
                  return  # pure-instruction skills allowed
              for py in scripts_dir.glob("*.py"):
                  text = py.read_text(encoding="utf-8", errors="replace")
                  rel = f"scripts/{py.name}"
                  if "argparse" not in text and "if __name__" in text:
                      self.add(
                          "warn",
                          "SCRIPT_NO_ARGPARSE",
                          f"{py.name} is a CLI but does not use argparse",
                          "Rewrite to use argparse for CLI parity with other skills",
                          file=rel,
                      )
                  if (
                      re.search(r"pip install .*--break-system-packages", text)
                      and re.search(r"(subprocess\.(run|call|Popen)|os\.system)", text)
                  ):
                      self.add(
                          "info",
                          "SCRIPT_PIP_FLAG",
                          f"{py.name} shells out pip install --break-system-packages (should be in docs, not code)",
                          "Move pip install guidance to SKILL.md/README.md",
                          file=rel,
                      )
                  if py.stat().st_size > 80_000:
                      self.add(
                          "info",
                          "SCRIPT_LARGE",
                          f"{py.name} is large (>80KB) — verify it's still a single-file CLI",
                          "",
                          file=rel,
                      )
                  # CJK-relevant text-rendering scripts should handle mixed text.
                  # Image-only helpers inside document skills (logo/QR compositing,
                  # PDF raster stitching, etc.) do not need font fallback logic.
                  renders_text = any(
                      marker in text.lower()
                      for marker in (
                          "add_run(",
                          "add_paragraph(",
                          "draw.text",
                          "imagefont",
                          "reportlab",
                          "fpdf",
                          "python-pptx",
                          "pptx",
                          "docx",
                      )
                  )
                  if renders_text and ("pdf" in self.name or "docx" in self.name or "deck" in self.name):
                      if "cjk" not in text.lower() and "chinese" not in text.lower() and "中文" not in text:
                          self.add(
                              "info",
                              "SCRIPT_NO_CJK_HINT",
                              f"{py.name}: document-skill script has no visible CJK handling code",
                              "Verify CJK/Latin mixed rendering works correctly",
                              file=rel,
                          )
      
          def run(self) -> list[dict]:
              self.check_structure()
              self.check_skill_md()
              self.check_readme()
              self.check_changelog()
              self.check_scripts()
              self.check_portability()
              return self.findings
      
      
      def format_text(findings: list[dict], skill_dir: Path) -> str:
          if not findings:
              return f"✓ {skill_dir.name}: no issues found\n"
          lines = [f"Lint report for {skill_dir.name}:", ""]
          by_sev = {s: [f for f in findings if f["severity"] == s] for s in SEVERITIES}
          marks = {"error": "✗", "warn": "!", "info": "·"}
          for sev in SEVERITIES:
              for f in by_sev[sev]:
                  loc = f" [{f['file']}]" if f["file"] else ""
                  lines.append(f"  {marks[sev]} {sev.upper():5} {f['code']:20}{loc}  {f['message']}")
                  if f["fix_hint"]:
                      lines.append(f"      → {f['fix_hint']}")
          lines.append("")
          lines.append(
              f"Summary: {len(by_sev['error'])} errors, {len(by_sev['warn'])} warnings, {len(by_sev['info'])} info"
          )
          return "\n".join(lines) + "\n"
      
      
      def main():
          ap = argparse.ArgumentParser(description="Audit a LovStudio/Agent Skill")
          ap.add_argument("name", nargs="?", help="Skill name (with or without lov- prefix)")
          ap.add_argument("--path", help="Absolute path to skill directory (overrides name)")
          ap.add_argument("--all", action="store_true", help="Audit every SKILL.md below the detected root")
          ap.add_argument("--root", help="Root directory for --all (defaults to detected skills root)")
          ap.add_argument("--json", action="store_true", help="Output findings as JSON")
          args = ap.parse_args()
      
          if args.all:
              root = Path(args.root).resolve() if args.root else find_repo_root(Path.cwd())
              reports = []
              for skill_dir in discover_skill_dirs(root):
                  linter = Linter(skill_dir)
                  reports.append({"skill": skill_dir.name, "path": str(skill_dir), "findings": linter.run()})
              if args.json:
                  print(
                      json.dumps(
                          {
                              "root": str(root),
                              "reports": reports,
                              "counts": {
                                  severity: sum(
                                      1
                                      for report in reports
                                      for finding in report["findings"]
                                      if finding["severity"] == severity
                                  )
                                  for severity in SEVERITIES
                              },
                          },
                          ensure_ascii=False,
                          indent=2,
                      )
                  )
              else:
                  total = {"error": 0, "warn": 0, "info": 0}
                  for report in reports:
                      counts = {sev: len([f for f in report["findings"] if f["severity"] == sev]) for sev in SEVERITIES}
                      for sev in SEVERITIES:
                          total[sev] += counts[sev]
                      print(
                          f"{report['skill']}: "
                          f"{counts['error']} errors, {counts['warn']} warnings, {counts['info']} info"
                      )
                  print(
                      f"\nSummary: {len(reports)} skills, "
                      f"{total['error']} errors, {total['warn']} warnings, {total['info']} info"
                  )
              if any(f["severity"] == "error" for r in reports for f in r["findings"]):
                  sys.exit(2)
              return
      
          if not args.name and not args.path:
              ap.error("provide a skill name or --path")
      
          skill_dir = resolve_skill_dir(args.name or "", args.path)
          linter = Linter(skill_dir)
          findings = linter.run()
      
          if args.json:
              counts = {severity: sum(1 for f in findings if f["severity"] == severity) for severity in SEVERITIES}
              print(
                  json.dumps(
                      {"skill": skill_dir.name, "path": str(skill_dir), "counts": counts, "findings": findings},
                      ensure_ascii=False,
                      indent=2,
                  )
              )
          else:
              sys.stdout.write(format_text(findings, skill_dir))
      
          # Exit non-zero if errors present
          if any(f["severity"] == "error" for f in findings):
              sys.exit(2)
      
      
      if __name__ == "__main__":
          main()
      
    • sync_installation.py 5.8 KB
      #!/usr/bin/env python3
      """Plan or apply a canonical Skill tree sync to installation copies.
      
      The default mode is read-only. Symlink installations are reported as already
      source-backed; copy installations can be updated explicitly with --apply.
      Extra target files are retained unless --prune is supplied together with
      --apply.
      """
      
      from __future__ import annotations
      
      import argparse
      import hashlib
      import json
      import shutil
      import sys
      from pathlib import Path
      
      
      IGNORED_DIRS = {
          ".git",
          ".worktrees",
          "node_modules",
          "dist",
          "build",
          "target",
          ".venv",
          "__pycache__",
      }
      
      
      def payload_files(directory: Path) -> dict[str, Path]:
          files: dict[str, Path] = {}
          if not directory.is_dir():
              return files
          for path in directory.rglob("*"):
              relative = path.relative_to(directory)
              if any(part in IGNORED_DIRS for part in relative.parts):
                  continue
              if path.is_file() and not path.is_symlink():
                  files[relative.as_posix()] = path
          return files
      
      
      def distribution_payload(source: Path) -> Path:
          public = source / "public"
          if (source / "src" / "SKILL.md").exists() and (public / "SKILL.md").exists():
              return public
          return source
      
      
      def file_digest(path: Path) -> str:
          digest = hashlib.sha256()
          with path.open("rb") as handle:
              for chunk in iter(lambda: handle.read(1024 * 1024), b""):
                  digest.update(chunk)
          return digest.hexdigest()
      
      
      def compare(source: Path, target: Path) -> dict:
          source_files = payload_files(source)
          target_files = payload_files(target)
          missing = sorted(set(source_files) - set(target_files))
          changed = sorted(
              relative
              for relative in set(source_files) & set(target_files)
              if file_digest(source_files[relative]) != file_digest(target_files[relative])
          )
          extra = sorted(set(target_files) - set(source_files))
          return {"missing": missing, "changed": changed, "extra": extra}
      
      
      def remove_extra_files(target: Path, relative_paths: list[str]) -> None:
          for relative in relative_paths:
              path = target / relative
              if path.is_file() or path.is_symlink():
                  path.unlink()
          directories = sorted(
              {path.parent for path in (target / relative for relative in relative_paths)},
              key=lambda path: len(path.parts),
              reverse=True,
          )
          for directory in directories:
              if directory == target:
                  continue
              try:
                  directory.rmdir()
              except OSError:
                  pass
      
      
      def copy_files(source: Path, target: Path, relative_paths: list[str]) -> None:
          for relative in relative_paths:
              source_path = source / relative
              target_path = target / relative
              target_path.parent.mkdir(parents=True, exist_ok=True)
              shutil.copy2(source_path, target_path)
      
      
      def sync_target(source: Path, target: Path, apply: bool, prune: bool, canonical_source: Path | None = None) -> dict:
          source = source.resolve()
          canonical_source = canonical_source.resolve() if canonical_source else source
          target_is_symlink = target.is_symlink()
          if target_is_symlink:
              resolved = target.resolve()
              state = "synced" if resolved in {source, canonical_source} else "drifted"
              return {
                  "path": str(target),
                  "kind": "symlink",
                  "resolved": str(resolved),
                  "state": state,
                  "action": "none",
              }
      
          if target.resolve() == source:
              raise ValueError(f"target resolves to source: {target}")
      
          target.mkdir(parents=True, exist_ok=True)
          before = compare(source, target)
          if apply:
              copy_files(source, target, before["missing"] + before["changed"])
              if prune:
                  remove_extra_files(target, before["extra"])
          after = compare(source, target)
          state = "synced" if not any(after.values()) else "drifted"
          return {
              "path": str(target),
              "kind": "copy",
              "state": state,
              "action": "applied" if apply else "planned",
              "before": before,
              "after": after,
          }
      
      
      def main() -> None:
          parser = argparse.ArgumentParser(description="Plan or sync a Skill installation copy")
          parser.add_argument("--source", required=True, help="Canonical Skill directory")
          parser.add_argument("--target", action="append", required=True, help="Installation directory; repeatable")
          parser.add_argument("--apply", action="store_true", help="Copy canonical files to targets")
          parser.add_argument("--prune", action="store_true", help="Remove extra target files; requires --apply")
          parser.add_argument("--json", action="store_true", help="Emit a machine-readable result")
          args = parser.parse_args()
      
          if args.prune and not args.apply:
              parser.error("--prune requires --apply")
      
          canonical_source = Path(args.source).expanduser().resolve()
          if not canonical_source.is_dir():
              parser.error(f"source directory not found: {canonical_source}")
          source = distribution_payload(canonical_source)
      
          try:
              targets = [Path(value).expanduser() for value in args.target]
              result = {
                  "source": str(canonical_source),
                  "payload": str(source),
                  "mode": "apply" if args.apply else "plan",
                  "prune": args.prune,
                  "targets": [
                      sync_target(source, target, args.apply, args.prune, canonical_source)
                      for target in targets
                  ],
              }
          except (OSError, ValueError) as error:
              print(f"ERROR: {error}", file=sys.stderr)
              raise SystemExit(1) from error
      
          if args.json:
              print(json.dumps(result, ensure_ascii=False, indent=2))
          else:
              print(f"source: {source}")
              for target in result["targets"]:
                  print(f"target: {target['path']} [{target['state']}; {target['kind']}; {target['action']}]")
      
      
      if __name__ == "__main__":
          main()
      
  • CHANGELOG.md 3.5 KB
    # Changelog
    
    All notable changes to this skill are documented here.
    Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/)
    
    ## [0.11.1] - 2026-08-31
    
    ### Fixed
    
    - discover the unified lovstudio-skills catalog beside source repositories
    
    ## [0.11.0] - 2026-08-31
    
    ### Added
    
    - support paid Skill repositories with src and public payload layouts
    - separate approved baselines, requested deltas, and rejected remedies before optimization
    - version authored and public Skill surfaces together
    - detect stale encrypted manifests before distribution sync
    
    ## [0.10.0] - 2026-08-30
    
    ### Added
    
    - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill
    - audit audience-visible Skills for the branding consistency dependency
    - preserve authoritative source text while gating authored presentation copy
    
    ## [0.9.0] - 2026-08-24
    
    ### Added
    
    - classify task, skill, and global feedback scope
    - store all-Skill feedback policy in the user-level shared instruction layer and invalidate pre-correction approval
    
    ## [0.8.0] - 2026-08-12
    
    ### Added
    
    - add guarded installation-copy synchronization
    - compare catalog Skill digests instead of reporting discovery alone
    - keep agent installation roots configurable and symlink-aware
    
    ## [0.7.1] - 2026-08-12
    
    ### Fixed
    
    - separate distribution and catalog synchronization states
    - keep aggregate sync partial when a catalog is not discovered
    
    ## [0.7.0] - 2026-08-12
    
    ### Added
    
    - add path-aware layout and synchronization auditing
    - keep README, SKILL.md, skill.yaml, and CHANGELOG versions aligned
    - report dirty, drifted, partial, and undiscovered distribution states
    - avoid requiring brand identity for a maintenance-only workflow
    
    ## [0.6.3] - 2026-05-07
    
    ### Fixed
    
    - avoid CJK false positives for image-only helpers
    - limit document CJK hints to scripts that actually render text
    
    ## [0.6.2] - 2026-05-07
    
    ### Fixed
    
    - remove local path examples from optimizer docs
    - refresh publish sync instructions for source plus general/dev catalog repos
    
    ## [0.6.1] - 2026-05-07
    
    ### Fixed
    
    - recognize skill-publisher CLI install commands
    - treat npx skills add as the canonical README install surface
    
    ## [0.6.0] - 2026-05-07
    
    ### Added
    
    - recognize SKILL_SKILLS config namespace
    - recognize SKILL_MAINTAIN_PARTNERS skill-specific config cues
    - treat AGENT_SKILL and PARTNERS env names as legacy migration cues
    
    ## [0.5.0] - 2026-05-07
    
    ### Added
    
    - recognize generic AGENT_SKILL config cues
    - keep SKILL_* only as legacy compatibility cues
    
    ## [0.4.0] - 2026-05-06
    
    ### Added
    
    - add repo-wide portability audit
    - flag hard-coded local paths and legacy skill names
    - support --all --root scans across skill directories
    - ignore negative examples such as "do not hard-code ${SKILL_USER_ROOT}" when checking required local paths
    
    ## [0.3.0] - 2026-04-16
    
    ### Added
    
    - Add multi-repo sync in Step 7: source → pro-skills → ~/.claude/skills
    - Document 3-location topology (source, symlink, distribution)
    - Step 7b: auto-sync skill-publisher/pro-skills via skills-upstream remote
    - Fail-loud on partial sync instead of silent skip
    
    ## [0.2.0] - 2026-04-14
    
    ### Added
    
    - Add Step 7: auto commit & push after optimization
    - Update README pipeline diagram to include git push step
    
    ## [0.1.0] - 2026-04-10
    
    ### Added
    
    - initial release: lint + auto-bump + changelog pipeline
    - lint_skill.py audits frontmatter, README badge, scripts, structure
    - bump_version.py maintains README badge, SKILL.md version, CHANGELOG.md
    
  • README.md 3.1 KB
    # Skill 精修师 · Skill Refiner
    
    ![Version](https://img.shields.io/badge/version-0.11.1-CC785C)
    
    自动审计并优化 Agent Skill:按当前对话优先修复问题,统一 README、SKILL.md、
    `skill.yaml` 与 CHANGELOG 版本,然后核对规范源、安装副本和 catalog 的同步状态。
    支持独立 Skill 目录、嵌套仓库和已安装副本,不把未发现的位置当成已同步。
    付费 Skill 仓库会审计 `src/SKILL.md`,同步时只使用 `public/` 加密分发载荷,避免把
    明文规范误装到用户目录。
    
    用户反馈先分三层:仅当前任务、单个 Skill 可复用、所有 Skill 全局可复用。全局规则只进入
    用户级共享规范(例如生效中的 `AGENTS.md`),不会机械复制到每个 `SKILL.md`;如果之前放错
    了领域 Skill,会迁移到共享层并保留真正的领域规则。任何可复用修改都会使修改前的终稿确认
    失效,完成当前产物更新后停下等待下一步。
    
    ## 安装
    
    ```bash
    npx skills add lovstudio/skills --skill lov-skill-optimizer -y -g
    ```
    
    Requires: Python 3.8+(仅标准库);Git 用于源码提交/推送核验。
    
    ## 使用
    
    ```bash
    # 审计一个独立 Skill
    python3 scripts/lint_skill.py --path /absolute/path/to/skill --json
    
    # 检查规范源、安装副本和 catalog
    python3 scripts/inspect_layout.py --path /absolute/path/to/skill --json
    
    # 先比较安装副本,不写入
    python3 scripts/sync_installation.py \
      --source /absolute/path/to/canonical-skill \
      --target /absolute/path/to/installed-skill --json
    
    # 审阅后同步普通副本;符号链接只做校验
    python3 scripts/sync_installation.py \
      --source /absolute/path/to/canonical-skill \
      --target /absolute/path/to/installed-skill --apply --json
    
    # 统一版本并追加 CHANGELOG
    python3 scripts/bump_version.py \
      --path /absolute/path/to/skill \
      --type minor \
      --message "add guarded synchronization audit"
    
    # 审计一个 Skills 根目录
    python3 scripts/lint_skill.py --all --root /absolute/path/to/skills --json
    ```
    
    也可以使用 Skill 名称(`foo`、`lov-foo`、`foo-skill`);跨仓库或已安装副本优先
    使用 `--path`,以确保修改落在规范源上。
    
    ## 处理顺序
    
    1. 读取当前对话中的具体问题,区分已认可基线、请求增量和被否决的修复尝试;
    2. 运行 lint,检查 frontmatter、触发语句、可移植性、版本漂移和脚本 CLI;
    3. 只修改规范源;
    4. 统一版本、追加 CHANGELOG;
    5. 重新 lint 并读取布局检查结果;
    6. 比较并同步已发现的安装副本和 catalog;
    7. 精确 staging、提交并推送,逐层报告失败状态。
    
    同一请求中出现多个 Skill 时,按用户给出的顺序逐个处理,每个 Skill 单独 bump
    版本并单独输出结果块。
    
    ## 输出状态
    
    报告固定包含 `source`、`distribution`、`catalog`、`distribution state`、
    `catalog state` 和 `sync state`。安装副本已同步但 catalog 未发现时,整体仍为
    `partial`;发现 catalog 后还要比较匹配 Skill 的 digest;本地源码提交不等于
    catalog 或线上页面已更新。
    
    ## 许可
    
    MIT
    
  • SKILL.md 13.3 KB
    ---
    name: lov-skill-optimizer
    category: Meta Skills
    tagline: "Audit an Agent Skill, apply focused fixes, bump semver, and verify every distribution layer."
    description: >
      Audit and optimize one or more existing Agent Skills from a canonical source
      path, then bump semver, update README/SKILL.md/skill.yaml/CHANGELOG.md, and
      verify installed copies and catalog synchronization. Checks frontmatter,
      trigger quality, CLI hygiene, naming, portability, version drift, dirty
      worktrees, shared Skill feedback policy, and compatibility contracts. Use when the user asks to optimize,
      refine, audit, polish, or update a Skill, or mentions "优化 skill", "skill
      审计", "刷一遍 skill", "skill-optimizer", or "update skill changelog".
    license: MIT
    compatibility: >
      Requires Python 3.8+ (stdlib only). Git is optional for linting and required
      for source commit/push verification. Catalog synchronization is discovered
      from explicit paths, environment variables, or nearby checkouts; unavailable
      locations are reported rather than assumed.
    depends_on:
      - lov-branding-consistency
    metadata:
      author: lovstudio
      version: "0.11.1"
      tags: meta skill-maintenance versioning changelog lint portability sync
    ---
    
    # Skill 精修师 · Skill Refiner
    
    This is a non-interactive maintenance workflow. It infers the target and
    prioritizes issues from the current conversation, then supplements them with a
    generic lint pass. When several Skills are named in one request, process them
    in the order named and emit a separate result block for each Skill.
    
    ## Target and source resolution
    
    Prefer an explicit canonical path whenever the Skill is outside a conventional
    skills repository:
    
    ```bash
    python3 scripts/lint_skill.py --path /absolute/path/to/skill --json
    python3 scripts/inspect_layout.py --path /absolute/path/to/skill --json
    ```
    
    For a name, accept `foo`, `lov-foo`, or `foo-skill`. Resolution may find a
    source checkout, an installed copy, or a catalog entry. Before editing:
    
    1. Resolve symlinks and record the actual path.
    2. Identify the Git root, branch, and dirty worktree state.
    3. Treat a source checkout as canonical. If the target is an installed copy,
       locate a matching source checkout; when no source exists, report that the
       supplied path itself is canonical instead of silently editing another copy.
    4. Record every discovered installation and catalog path. A copy is `synced`
       only when its content digest matches the source; a missing location is
       `not_discovered`, never `complete`. A symlink is `synced` only when it
       resolves to the canonical source.
    
    Paid/encrypted repositories may keep the authored specification at
    `src/SKILL.md` and the installable payload under `public/`. Treat the repository
    root as canonical, lint and version the authored spec, and compare or sync only
    the `public/` payload to installations. Never copy plaintext `src/` content into
    an installed paid Skill.
    
    Do not absorb pre-existing edits into a maintenance commit. If a target file is
    already dirty, review the overlap before editing it and stage only the exact
    files changed by this optimization.
    
    ## Workflow (mandatory)
    
    ### Step 0: Classify feedback scope
    
    When the optimization is triggered by a user correction, classify it before choosing targets:
    
    - `task-specific`: applies only to the current artifact or one-off value. Do not edit a Skill.
    - `skill-specific reusable`: applies to future runs of one domain or platform. Optimize the relevant
      canonical Skill.
    - `global reusable`: changes how every Skill should handle feedback, authorization, sequencing, or
      handoff. Update the host's active user-level shared instruction artifact once (for example the
      applicable user-level `AGENTS.md`); do **not** paste the same policy into every domain `SKILL.md`.
    
    If a global policy was previously placed in one domain Skill, move it to the shared layer and remove the
    domain duplicate while preserving any genuinely domain-specific rule learned in the same incident. A
    shared policy file may be outside the Skill repository; report its path and verification state explicitly
    instead of pretending it is part of the Skill package.
    
    Any `reusable` correction invalidates prior terminal approval for the active task. Finish the policy/Skill
    optimization and validation first, then apply the correction to the current artifact, report the new state,
    and stop for the user's next instruction. Do not continue into publishing, submission, or another external
    write using a pre-correction “confirm”, “continue”, or “go ahead”.
    
    ### Step 1: Extract targets and context
    
    Normalize every explicitly named Skill and preserve the user's order. Strip the
    `lov-` prefix only for lookup; keep the public Skill identifier in reports.
    Collect the current-conversation fix list first: broken flags, trigger misses,
    wrong paths, confusing output, missing modules, compatibility requirements, or
    other concrete symptoms.
    
    For every correction, distinguish four pieces of evidence: the observed
    defect, the latest user-approved baseline, the requested delta, and any remedy
    the user rejected. A rejected remedy is negative evidence, not a reusable best
    practice. Preserve the approved baseline outside the requested delta, and do
    not encode a failed over-correction into the target Skill.
    
    ### Step 2: Baseline lint
    
    For each target, run the linter against the resolved canonical path:
    
    ```bash
    python3 scripts/lint_skill.py --path /absolute/path/to/skill --json
    ```
    
    Prioritize findings in this order:
    
    1. Fixes explicitly mentioned in the conversation;
    2. `error` findings;
    3. `warn` findings;
    4. cheap, low-risk `info` findings.
    
    The baseline must include version-source drift between README.md, SKILL.md
    frontmatter, and skill.yaml. Portability findings are high priority for a
    reusable Skill: move personal paths to flags, environment variables, or
    `references/user-config.md`, or mark a genuinely author-only dependency in
    `compatibility`.
    
    Classify the Skill's normal output before editing. If it generates, edits,
    reviews, renders, packages, or publishes text visible to an end user or reader,
    top-level `depends_on` must contain `lov-branding-consistency`. This dependency
    gates authored presentation copy only; it must not rewrite quotations,
    transcripts, source data, legal text, identifiers, or code without permission.
    
    ### Step 3: Apply focused fixes
    
    Edit only the canonical source. Keep the Skill's public trigger surface,
    compatibility aliases, storage contracts, and user-facing semantics explicit.
    Use progressive disclosure when SKILL.md grows beyond roughly 500 lines. Add a
    script or reference file only when it resolves a concrete audit finding or
    conversation issue.
    
    When the user has already approved an artifact and then requests a localized
    fix, optimize the domain Skill around a minimal-delta contract: lock accepted
    layout, copy, data, and behavior as invariants; change only the defective
    region; verify the whole artifact afterward. A broad redesign requires an
    explicit new request, not an inferred opportunity.
    
    The linter checks both root `SKILL.md` repositories and paid/encrypted
    `src/SKILL.md` repositories:
    
    - Agent Skills-compatible frontmatter and trigger phrases;
    - README version badge and installation command;
    - `metadata.version`, README badge, and `skill.yaml` version consistency;
    - CLI use of argparse and obvious script hygiene;
    - TODO placeholders and oversized instruction bodies;
    - personal paths, fixed runtime paths, and missing user configuration;
    - source/install/catalog layout evidence.
    - missing `lov-branding-consistency` dependency for audience-visible text Skills.
    
    ### Step 4: Bump semver and changelog
    
    Use the path-aware version tool so all version surfaces move together:
    
    ```bash
    python3 scripts/bump_version.py \
      --path /absolute/path/to/skill \
      --type minor \
      --message "add guarded project rename workflow" \
      --change "report source, installation, and catalog synchronization state"
    ```
    
    Choose `patch` for bug, wording, frontmatter, or lint fixes; `minor` for a new
    flag, reference, module, or expanded workflow; `major` for a breaking CLI or
    removed behavior. Stay in `0.x` unless the user explicitly requests otherwise.
    The tool updates README.md, the authored SKILL.md, paid-skill public wrappers,
    skill.yaml, and CHANGELOG.md and refuses to duplicate an existing changelog
    version.
    
    ### Step 5: Re-lint and inspect layout
    
    ```bash
    python3 scripts/lint_skill.py --path /absolute/path/to/skill --json
    python3 scripts/inspect_layout.py --path /absolute/path/to/skill --json
    ```
    
    Do not report `remaining lint warnings: none` unless the final JSON was read.
    Do not report synchronization as complete unless every discovered distribution
    copy and required catalog check has been verified after the source change. Keep
    `distribution_state`, `catalog_state`, and the aggregate `sync_state` separate:
    an installed copy can be `complete` while an undiscovered catalog keeps the
    aggregate state `partial`. A discovered catalog is `complete` only when its
    matching Skill payload digest is `synced`.
    
    ### Step 6: Synchronize discovered distributions
    
    `inspect_layout.py` checks conventional and configured installation roots:
    `AGENT_SKILLS_DIR`, `CLAUDE_SKILLS_DIR`, `CODEX_SKILLS_DIR`, `SKILLS_DIR`,
    plus the host's agent-managed fallback roots. It also checks explicit
    `--install-root` and `--catalog-root` values plus the nearby unified
    `lovstudio-skills` catalog and legacy general/dev catalog names. Use an
    environment variable or explicit flag when the installation root is outside
    the conventional layout.
    
    For a non-symlink installation copy, first run a read-only sync plan. Paid
    repositories automatically use their `public/` payload:
    
    ```bash
    python3 scripts/sync_installation.py \
      --source /absolute/path/to/canonical-skill \
      --target /absolute/path/to/installed-skill \
      --json
    ```
    
    After reviewing `missing`, `changed`, and `extra`, apply the exact copy with:
    
    ```bash
    python3 scripts/sync_installation.py \
      --source /absolute/path/to/canonical-skill \
      --target /absolute/path/to/installed-skill \
      --apply --json
    ```
    
    Use `--prune` only when removing extra files from the installation copy is
    explicitly part of the task. Symlink installations are verified, not copied.
    For a catalog, use its own scripts only when they are actually present:
    
    ```bash
    python3 scripts/sync-skills.py
    python3 scripts/render-marketplace.py
    python3 scripts/render-readme.py
    python3 scripts/validate_deps.py
    ```
    
    Run only the commands that exist in that catalog checkout. If no catalog is
    discovered, report `not_discovered`; if source and installation are updated but
    the catalog is stale or unavailable, report `partial`. Never invent a catalog
    path or claim a live-site update from a local source commit.
    
    ### Step 7: Commit and push exact source changes
    
    Inspect `git diff --check`, then stage the listed changed files explicitly:
    
    ```bash
    git add -- SKILL.md README.md CHANGELOG.md skill.yaml scripts references
    git diff --cached --name-only
    git commit -m "fix(<skill-name>): <one-line summary>"
    git push origin HEAD
    ```
    
    Use `feat` for a minor feature and `feat!` for a breaking change. If the source
    checkout has no remote, commit on its current branch and report `push:
    not_configured`; do not imply that a remote release happened. If a catalog is a
    separate repository, commit and push it independently after its own validation.
    
    ## Final report contract
    
    Return one block per optimized target, with no trailing summary:
    
    ```
    optimized: lov-<name>
    version:   <old> → <new>
    source:    <canonical path> (<clean|dirty>)
    distribution:
      - <path>: <synced|drifted|not_discovered>
    catalog:
      - <path>: <synced|partial|not_discovered>
    fixes:
      - <bullet 1>
      - <bullet 2>
    remaining lint warnings: <count>  (or "none")
    sync state: <complete|partial|not_discovered>
    ```
    
    The keys stay stable for machine parsing; the values and fix bullets follow the
    user's language. A failed or skipped push, installation sync, or catalog sync
    must appear in the relevant state rather than being omitted.
    
    ## Runtime context
    
    Read this Skill's `skill.yaml` when the host supplies `skill-runtime/v1`.
    Use only fields declared there. Profile data is for public identity facts and
    preferences are for output language/timezone; neither replaces the canonical
    source, installation, or catalog evidence collected by this workflow.
    
    ## CLI reference
    
    ```bash
    python3 scripts/lint_skill.py --path PATH [--json]
    python3 scripts/lint_skill.py --all --root PATH [--json]
    python3 scripts/bump_version.py --path PATH --type patch|minor|major -m MESSAGE
    python3 scripts/inspect_layout.py --path PATH [--install-root PATH] [--catalog-root PATH] [--json]
    python3 scripts/sync_installation.py --source PATH --target PATH [--apply] [--prune] [--json]
    ```
    
    All bundled tools use Python's standard library only.
    
    ## 通用反馈闭环
    
    用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
    
    1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。
    2. `task-specific` 只修改当前任务,不改 Skill。
    3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
    4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
    5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
    
  • skill.yaml 846 B
    schema: skill-manifest/v1
    id: lov-skill-optimizer
    version: "0.11.1"
    runtime: skill-runtime/v1
    context:
      profile:
        fields:
        - path: identity.name
          required: false
          question: 如果本次输出需要品牌身份,请提供品牌名称。
        - path: identity.logo
          required: false
          question: 如果需要使用品牌 Logo,请提供 Logo 地址或文件路径。
        - path: brand.tone
          required: false
          question: 如果已有品牌语气或审美关键词,请提供它们。
      preferences:
        namespace: lov_skill_optimizer
        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