
Lovstudio:Skill Optimizer
- 2 installs
- Updated July 13, 2026
- lovstudio/skill-optimizer-skill
Audits and auto-fixes an existing lovstudio skill, then bumps its semver version and appends a CHANGELOG entry.
About
Audits and auto-fixes an existing lovstudio skill against repo conventions, then bumps its semver version and appends a CHANGELOG entry. A developer uses it to polish, standardize, or version-bump a skill they already have.
- Runs a lint to fix to version-bump to changelog pipeline on an existing skill
- Prioritizes issues raised in the current conversation over a generic sweep
Lovstudio:Skill Optimizer by the numbers
- 2 all-time installs (skills.sh)
- Ranked #611 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Jul 25, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lovstudio/skill-optimizer-skill --skill lovstudioskill-optimizerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| Last updated | July 13, 2026 |
| Repository | lovstudio/skill-optimizer-skill ↗ |
What it does
Audits and auto-fixes an existing lovstudio skill, then bumps its semver version and appends a CHANGELOG entry.
Files
skill-optimizer — 自动优化 lovstudio skill 并维护版本与 changelog
Runs a lint → fix → bump → changelog pipeline on an existing skill in this repo. Fully automatic — no interactive prompts. Every run produces a concrete version bump and a new CHANGELOG.md entry so optimization history is traceable.
When to Use
- User mentioned a concrete problem with an existing skill in this conversation
(wrong trigger phrases, stale CLI flags, missing README badge, etc.) and wants it fixed.
- User asks to "audit", "polish", "refine", "刷一遍" an existing skill.
- User explicitly asks to bump a skill's version or update its changelog.
- Proactively after another skill has been modified meaningfully — run this to
record the change with a version bump.
Workflow (MANDATORY, fully automatic)
Do not ask the user for options. Infer everything from (a) the target skill name and (b) any optimization notes mentioned in the current conversation.
Step 1: Identify target & context
From the user's message, extract:
1. Target skill name — e.g. any2pdf, lovstudio-any2pdf, or lovstudio:any2pdf. Normalize to the bare name (strip prefix). If the user did not name a skill explicitly, infer it from recent conversation context (the skill they were just working on). If still ambiguous, ask one targeted question. 2. Context-driven fix list — scan the current conversation for issues the user raised about this skill: wrong flags, trigger misfires, broken CJK rendering, missing options, confusing README, etc. This list is the primary driver. The generic lint only supplements it.
Step 2: Lint
python3 skills/lovstudio-skill-optimizer/scripts/lint_skill.py <name> --jsonParse the JSON findings. Combine with the context-driven fix list from Step 1. Prioritize in this order:
1. Fixes the user explicitly mentioned in conversation (highest priority) 2. Lint error findings 3. Lint warn findings 4. Lint info findings — apply only if cheap and safe
Step 3: Apply fixes directly
Edit SKILL.md, README.md, scripts/*.py with the Edit tool based on the prioritized fix list. Guidelines:
- SKILL.md frontmatter description: make sure it covers what + when +
concrete trigger phrases (中文 + English). Don't bloat it; keep under ~800 chars.
- CLI args in scripts: if the user hit a bug with a specific flag, fix the
root cause — don't paper over it.
- Progressive disclosure: if SKILL.md body > 500 lines, split the largest
section to references/<topic>.md.
- Don't write tests or docs that weren't asked for. The CHANGELOG entry IS
the documentation of the change.
- Don't add emojis unless the original file already uses them consistently.
Step 4: Decide bump type
Choose semver bump based on the fixes applied:
| Bump | Use when |
|---|---|
patch | bug fix, wording fix, frontmatter tweak, CJK rendering fix |
minor | new CLI flag, new option, new reference doc, expanded scope |
major | breaking CLI change, removed option, renamed skill |
Stay in 0.x unless explicitly told otherwise — per repo release conventions.
Step 5: Bump version + write changelog
python3 skills/lovstudio-skill-optimizer/scripts/bump_version.py <name> \
--type <patch|minor|major> \
--message "<one-line summary of the biggest change>" \
--change "<additional bullet>" \
--change "<additional bullet>"This updates:
README.mdversion badge (source of truth)SKILL.mdfrontmattermetadata.version(kept in sync if present)CHANGELOG.md— prepends a new entry with today's date (creates the file if missing)
Step 6: Re-lint & report
python3 skills/lovstudio-skill-optimizer/scripts/lint_skill.py <name>Report to the user, in this exact shape and nothing more:
optimized: lovstudio-<name>
version: <old> → <new>
fixes:
- <bullet 1>
- <bullet 2>
remaining lint warnings: <count> (or "none")Do not print a trailing summary, self-congratulation, or next-step suggestions. The diff speaks for itself.
Step 7: Commit, push & sync all locations
Skills live in three locations that must stay in sync:
source (edit here): ~/projects/lovstudio-skills/ → lovstudio/skills
claude reads from: ~/.claude/skills/* → symlinks to source
distribution repo: ~/projects/lovstudio-pro-skills/ → lovstudio/pro-skills7a. Commit & push to source repo:
cd ~/projects/lovstudio-skills
git add skills/lovstudio-<name>/
git commit -m "fix(<name>): <one-line summary>"
git push- Commit message follows repo convention:
fix|feat|docs(<skill-name>): <summary> - Use
fixfor patch,featfor minor,feat!for major
7b. Sync to pro-skills distribution repo:
The two repos have independent git histories (not forkable), so sync via file copy:
SRC=~/projects/lovstudio-skills/skills/lovstudio-<name>
DST=~/projects/lovstudio-pro-skills/skills/lovstudio-<name>
rsync -av --delete "$SRC/" "$DST/"
cd ~/projects/lovstudio-pro-skills
git add skills/lovstudio-<name>/
git commit -m "sync(<name>): <version> from lovstudio/skills"
git push origin main~/.claude/skills/ is already up-to-date via symlinks — no action needed.
If any step fails, report the sync state to the user rather than silently skipping. A partial sync (source updated but pro-skills stale) is the exact problem this step exists to prevent.
CLI Reference
lint_skill.py
| Argument | Default | Description |
|---|---|---|
<name> | — | Skill name (with or without lovstudio- prefix) |
--path | — | Absolute path to skill dir (overrides name) |
--json | off | Emit findings as JSON |
Exit code: 2 if any error-severity finding, 0 otherwise.
bump_version.py
| Argument | Default | Description |
|---|---|---|
<name> | — | Skill name |
--path | — | Absolute path to skill dir (overrides name) |
--type | — | patch \ |
--set | — | Explicit version e.g. 0.2.0 |
--message, -m | required | Primary changelog bullet |
--change, -c | — | Additional bullet (repeatable) |
--dry-run | off | Show what would change without writing |
Dependencies
Python 3.8+ (stdlib only, no pip install needed).
Changelog
All notable changes to this skill are documented here. Format: Keep a Changelog · Versioning: SemVer
[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 lovstudio/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
lovstudio:skill-optimizer
自动优化 lovstudio skill — 审计规范、应用修复、bump 版本、追加 changelog。
Part of lovstudio/skills — by lovstudio.ai
Install
npx skills add lovstudio/skills --skill lovstudio:skill-optimizerRequires: Python 3.8+ (stdlib only, no pip install)
What It Does
┌──────────────────────────────────────────────────┐
│ skill-optimizer pipeline │
├──────────────────────────────────────────────────┤
│ 1. lint 审计 SKILL.md / scripts / README │
│ 2. apply 优先修复对话中提到的问题 │
│ 3. bump patch / minor / major │
│ 4. changelog 追加 Keep a Changelog 条目 │
│ 5. re-lint 验证 + 输出简报 │
│ 6. commit git add + commit + push │
└──────────────────────────────────────────────────┘Fully automatic — no interactive prompts. Optimizations are driven by issues raised in the current conversation, supplemented by a generic lint pass.
Usage
In Claude Code
/lovstudio:skill-optimizer any2pdf
# or with explicit focus:
/lovstudio:skill-optimizer any2pdf — the --theme flag trigger phrase is wrongClaude will: 1. Run lint_skill.py for a baseline audit. 2. Read any conversation context about problems the user hit with this skill. 3. Edit SKILL.md / README.md / scripts to fix prioritized issues. 4. Run bump_version.py to bump the version and prepend a CHANGELOG.md entry. 5. Report what changed. 6. Commit and push to GitHub.
CLI
Audit only:
python3 scripts/lint_skill.py any2pdf
python3 scripts/lint_skill.py any2pdf --jsonBump version and write changelog:
python3 scripts/bump_version.py any2pdf \
--type patch \
--message "fix CJK line-wrap in bullet lists" \
--change "tighten frontmatter trigger phrases"Dry run (show without writing):
python3 scripts/bump_version.py any2pdf --type patch -m "..." --dry-runWhat Gets Audited
| Check | Severity | What it catches |
|---|---|---|
Directory prefix lovstudio- | error | Wrong dir naming |
SKILL.md / README.md present | error | Missing core files |
| Frontmatter required fields | error | Missing name / description / license / compatibility / metadata |
name matches directory | error | lovstudio:foo vs lovstudio-bar drift |
| Description has trigger cues | warn | Missing "Use when..." or "trigger when user mentions..." |
| Description length | warn | < 80 chars likely insufficient |
metadata.version semver format | warn | Non-x.y.z versions |
| README version badge | warn | Missing  |
| README install command | warn | Missing npx skills add ... |
CHANGELOG.md exists | warn | No changelog |
| Scripts use argparse | warn | CLI without argparse |
| SKILL.md body length | warn | > 500 lines — should split to references/ |
| TODO placeholders | error | Uninitialized template content |
| CJK handling for doc skills | info | Document skills without visible CJK code paths |
Version Bump Rules
| Bump | When to use |
|---|---|
patch | bug fix, wording, frontmatter tweak, CJK rendering fix |
minor | new flag/option/reference doc, expanded scope |
major | breaking CLI change, removed option, rename |
Per repo convention: stay in 0.x unless explicitly authorized.
Options
lint_skill.py
| Option | Default | Description |
|---|---|---|
<name> | — | Skill name (with/without lovstudio- prefix) |
--path | — | Absolute path to skill dir |
--json | off | Emit JSON |
bump_version.py
| Option | Default | Description |
|---|---|---|
<name> | — | Skill name |
--path | — | Absolute path (overrides name) |
--type | — | patch / minor / major |
--set | — | Explicit version e.g. 0.2.0 |
--message, -m | required | Primary changelog bullet |
--change, -c | — | Additional bullet (repeatable) |
--dry-run | off | Preview without writing |
License
MIT
#!/usr/bin/env python3
"""
Bump a lovstudio skill's version and append a CHANGELOG entry.
Source of truth: README.md version badge. SKILL.md frontmatter metadata.version
is kept in sync when present.
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 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'(\n\s*version:\s*")(\d+\.\d+\.\d+)(")')
def find_repo_root(start: Path) -> Path:
for p in [start] + list(start.parents):
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()
name = name.removeprefix("lovstudio-").removeprefix("lovstudio:")
root = find_repo_root(Path.cwd())
return (root / "skills" / f"lovstudio-{name}").resolve()
def read_current_version(skill_dir: Path) -> str:
readme = skill_dir / "README.md"
if readme.exists():
m = BADGE_RE.search(readme.read_text(encoding="utf-8"))
if m:
return m.group(1)
skill_md = skill_dir / "SKILL.md"
if skill_md.exists():
m = FM_VERSION_RE.search(skill_md.read_text(encoding="utf-8"))
if m:
return m.group(2)
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_skill_md_version(skill_dir: Path, new_version: str, dry: bool) -> bool:
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
return False
text = skill_md.read_text(encoding="utf-8")
if not FM_VERSION_RE.search(text):
return False
new_text = FM_VERSION_RE.sub(rf'\g<1>{new_version}\g<3>', text, count=1)
if new_text != text and not dry:
skill_md.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 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 a lovstudio skill")
ap.add_argument("name", nargs="?", help="Skill name (with or without lovstudio- prefix)")
ap.add_argument("--path", help="Absolute path to skill directory (overrides name)")
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")
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)
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)")
update_readme(skill_dir, new_version, args.dry_run)
update_skill_md_version(skill_dir, new_version, args.dry_run)
update_changelog(skill_dir, new_version, args.message, args.change, kind, args.dry_run)
print("done.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Audit a lovstudio skill against repo conventions + official skill-creator best practices.
Usage:
python lint_skill.py <skill-name> # e.g. any2pdf or lovstudio-any2pdf
python lint_skill.py <skill-name> --json
python lint_skill.py --path /abs/path/to/skills/lovstudio-any2pdf
Outputs a list of findings with severity (error/warn/info) and a `fix_hint` field
to help the orchestrating skill prioritize automatic fixes.
"""
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"]
def find_repo_root(start: Path) -> Path:
for p in [start] + list(start.parents):
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()
name = name.removeprefix("lovstudio-").removeprefix("lovstudio:")
root = find_repo_root(Path.cwd())
return (root / "skills" / f"lovstudio-{name}").resolve()
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
self.name = skill_dir.name.removeprefix("lovstudio-")
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
if not self.dir.name.startswith("lovstudio-"):
self.add(
"error",
"DIR_PREFIX",
f"Directory '{self.dir.name}' must start with 'lovstudio-'",
"Rename directory to lovstudio-<name>",
)
for required in ("SKILL.md", "README.md"):
f = self.dir / required
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 = self.dir / "SKILL.md"
if not path.exists():
return
text = path.read_text(encoding="utf-8")
fm, body = parse_frontmatter(text)
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="SKILL.md",
)
name = fm.get("name", "")
if name and name != f"lovstudio:{self.name}":
self.add(
"error",
"FM_NAME_MISMATCH",
f"frontmatter name '{name}' does not match directory (expected 'lovstudio:{self.name}')",
f"Set name: lovstudio:{self.name}",
file="SKILL.md",
)
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="SKILL.md",
)
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="SKILL.md",
)
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="SKILL.md",
)
version = meta.get("version", "")
if version and not re.match(r"^\d+\.\d+\.\d+$", 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="SKILL.md",
)
# 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="SKILL.md",
)
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="SKILL.md",
)
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="SKILL.md",
)
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",
)
if "npx skills add lovstudio/skills" not in text:
self.add(
"warn",
"README_NO_INSTALL",
"README.md missing install command",
"Add install block: `npx skills add lovstudio/skills --skill lovstudio:<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 skills should handle mixed text
if "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()
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 skill")
ap.add_argument("name", nargs="?", help="Skill name (with or without lovstudio- prefix)")
ap.add_argument("--path", help="Absolute path to skill directory (overrides name)")
ap.add_argument("--json", action="store_true", help="Output findings as JSON")
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)
linter = Linter(skill_dir)
findings = linter.run()
if args.json:
print(
json.dumps(
{"skill": skill_dir.name, "path": str(skill_dir), "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()