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
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/skill-optimizer
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
git clone https://github.com/lovstudio/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.
README
Skill 精修师 · Skill Refiner
自动审计并优化 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,以确保修改落在规范源上。
处理顺序
- 读取当前对话中的具体问题,区分已认可基线、请求增量和被否决的修复尝试;
- 运行 lint,检查 frontmatter、触发语句、可移植性、版本漂移和脚本 CLI;
- 只修改规范源;
- 统一版本、追加 CHANGELOG;
- 重新 lint 并读取布局检查结果;
- 比较并同步已发现的安装副本和 catalog;
- 精确 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:
- Resolve symlinks and record the actual path.
- Identify the Git root, branch, and dirty worktree state.
- 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.
- Record every discovered installation and catalog path. A copy is
syncedonly when its content digest matches the source; a missing location isnot_discovered, nevercomplete. A symlink issyncedonly 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-levelAGENTS.md); do not paste the same policy into every domainSKILL.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:
- Fixes explicitly mentioned in the conversation;
errorfindings;warnfindings;- cheap, low-risk
infofindings.
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, andskill.yamlversion 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-consistencydependency 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 驱动任务中提出修改意见时,继续当前产物前必须执行:
- 先判断意见是
task-specific(仅本次)还是reusable(可跨任务复用)。 task-specific只修改当前任务,不改 Skill。reusable先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。- 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
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"" 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  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  自动审计并优化 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.
Reviews (0)
No reviews yet.
No comments yet.