feat(skills): add Agent Skill Stack (#2350)

Co-authored-by: neilchen2000-pixel <283394672+neilchen2000-pixel@users.noreply.github.com>
This commit is contained in:
NeilChen
2026-07-24 01:51:21 +08:00
committed by GitHub
parent f08f426454
commit 233d902c2f
12 changed files with 1611 additions and 0 deletions
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""Read-only inventory and overlap/risk indicator scan for agent skills."""
from __future__ import annotations
import argparse
import json
import os
import re
from pathlib import Path
from typing import Iterable
SKIP_DIRS = {
".git",
".archive",
".curator_backups",
".hub",
"__pycache__",
"node_modules",
}
SCRIPT_SUFFIXES = {".py", ".sh", ".js", ".ts", ".mjs", ".cjs", ".ps1", ".rb", ".go"}
STOPWORDS = {
"about", "agent", "agents", "also", "and", "any", "are", "can", "for", "from",
"help", "into", "its", "other", "skill", "skills", "that", "the", "their", "this",
"through", "tool", "tools", "use", "user", "users", "using", "when", "with", "workflow",
"一个", "一款", "一些", "什么", "可以", "帮我", "技能", "我想", "有没有", "这个", "这件",
}
RISK_PATTERNS = {
"destructive-command": re.compile(r"\brm\s+-[^\n]*r[^\n]*f|git\s+reset\s+--hard|shutil\.rmtree", re.I),
"credential-or-secret-access": re.compile(
r"\.ssh\b|\.aws\b|keychain|credential|cookie|secret|api[_-]?key|\.env\b|os\.environ", re.I
),
"network-or-download": re.compile(
r"\bcurl\b|\bwget\b|requests\.|httpx\.|urllib\.|fetch\s*\(|https?://", re.I
),
"dynamic-or-obfuscated-execution": re.compile(
r"base64[^\n]{0,80}(decode|-d)|\beval\s*\(|\bexec\s*\(|child_process|subprocess\.", re.I
),
"persistence-or-system-service": re.compile(r"\bcrontab\b|\blaunchctl\b|systemctl\s+enable|launchagents", re.I),
"privilege-or-broad-permission": re.compile(r"\bsudo\b|chmod\s+777|chown\s+-R", re.I),
"external-mutation-language": re.compile(
r"\b(publish|send|upload|delete|remove|purchase|comment|post)\b|发布|发送|上传|删除|购买|评论", re.I
),
"possible-hardcoded-token": re.compile(r"\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]{12,}\b|\bAKIA[A-Z0-9]{12,}\b"),
}
def parse_frontmatter(text: str) -> tuple[dict[str, str], list[str]]:
issues: list[str] = []
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return {}, ["missing opening frontmatter delimiter"]
try:
end = next(i for i in range(1, len(lines)) if lines[i].strip() == "---")
except StopIteration:
return {}, ["missing closing frontmatter delimiter"]
data: dict[str, str] = {}
i = 1
while i < end:
match = re.match(r"^([A-Za-z0-9_-]+):\s*(.*)$", lines[i])
if not match:
i += 1
continue
key, raw = match.group(1), match.group(2).strip()
if raw in {">", "|"}:
mode = raw
block: list[str] = []
i += 1
while i < end and (not lines[i].strip() or lines[i][:1].isspace()):
block.append(lines[i].strip())
i += 1
data[key] = (" " if mode == ">" else "\n").join(part for part in block if part)
continue
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {'"', "'"}:
raw = raw[1:-1]
data[key] = raw
i += 1
if not data.get("name"):
issues.append("missing name")
if not data.get("description"):
issues.append("missing description")
return data, issues
def iter_skill_files(root: Path) -> Iterable[Path]:
for current, dirs, files in os.walk(root, followlinks=False):
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
if "SKILL.md" in files:
yield Path(current) / "SKILL.md"
def tokenize(text: str) -> set[str]:
tokens = {
token for token in re.findall(r"[a-z][a-z0-9-]{2,}", text.lower())
if token not in STOPWORDS
}
for run in re.findall(r"[\u3400-\u9fff]{2,}", text):
if len(run) <= 8 and run not in STOPWORDS:
tokens.add(run)
tokens.update(run[i:i + 2] for i in range(len(run) - 1) if run[i:i + 2] not in STOPWORDS)
return set(sorted(tokens)[:120])
def scan_indicators(skill_dir: Path) -> list[dict[str, object]]:
findings: dict[tuple[str, str], int] = {}
candidates = [skill_dir / "SKILL.md"]
for current, dirs, files in os.walk(skill_dir, followlinks=False):
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
for filename in files:
path = Path(current) / filename
if path == skill_dir / "SKILL.md":
continue
if path.suffix.lower() in SCRIPT_SUFFIXES or filename in {"package.json", "pyproject.toml"}:
candidates.append(path)
for path in sorted(set(candidates))[:250]:
try:
if path.is_symlink() or path.stat().st_size > 1_000_000:
continue
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
relative = str(path.relative_to(skill_dir))
for label, pattern in RISK_PATTERNS.items():
count = len(pattern.findall(text))
if count:
findings[(label, relative)] = count
return [
{"indicator": label, "file": filename, "matches": count}
for (label, filename), count in sorted(findings.items())
]
def skill_record(skill_file: Path, root: Path) -> dict[str, object]:
try:
text = skill_file.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"path": str(skill_file.parent), "root": str(root), "issues": [f"read error: {exc}"]}
data, issues = parse_frontmatter(text[:300_000])
name = data.get("name", "")
description = data.get("description", "")
if name and skill_file.parent.name != name:
issues.append(f"directory name '{skill_file.parent.name}' differs from skill name '{name}'")
return {
"name": name,
"description": description,
"path": str(skill_file.parent),
"root": str(root),
"trigger_tokens": sorted(tokenize(f"{name} {description}")),
"risk_indicators": scan_indicators(skill_file.parent),
"issues": issues,
}
def find_overlaps(skills: list[dict[str, object]], threshold: float, limit: int) -> list[dict[str, object]]:
overlaps: list[dict[str, object]] = []
for i, left in enumerate(skills):
left_tokens = set(left.get("trigger_tokens", []))
if not left_tokens:
continue
for right in skills[i + 1:]:
right_tokens = set(right.get("trigger_tokens", []))
shared = left_tokens & right_tokens
union = left_tokens | right_tokens
if len(shared) < 3 or not union:
continue
score = len(shared) / len(union)
if score >= threshold:
overlaps.append({
"left": left.get("name") or left.get("path"),
"right": right.get("name") or right.get("path"),
"score": round(score, 3),
"shared_terms": sorted(shared)[:20],
})
overlaps.sort(key=lambda item: (-float(item["score"]), str(item["left"]), str(item["right"])))
return overlaps[:limit]
def render_markdown(report: dict[str, object]) -> str:
summary = report["summary"]
lines = [
"# Skill inventory",
"",
f"- Skills found: {summary['skills_found']}",
f"- Duplicate names: {summary['duplicate_names']}",
f"- Trigger overlaps reported: {summary['trigger_overlaps']}",
f"- Skills with indicators: {summary['skills_with_risk_indicators']}",
"",
"| Skill | Root | Issues | Indicators |",
"|---|---|---:|---:|",
]
for skill in report.get("skills", []):
lines.append(
f"| {skill.get('name') or '(invalid)'} | {skill.get('root')} | "
f"{len(skill.get('issues', []))} | {len(skill.get('risk_indicators', []))} |"
)
if report["duplicates"]:
lines.extend(["", "## Duplicate names", "", "```json", json.dumps(report["duplicates"], ensure_ascii=False, indent=2), "```"])
if report["overlaps"]:
lines.extend(["", "## Trigger overlaps", "", "```json", json.dumps(report["overlaps"], ensure_ascii=False, indent=2), "```"])
lines.extend(["", "> Indicators require manual review; they are not a malware verdict."])
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", action="append", required=True, help="Skill root; repeat for multiple roots")
parser.add_argument("--format", choices=("json", "markdown"), default="json")
parser.add_argument("--overlap-threshold", type=float, default=0.28)
parser.add_argument("--max-overlaps", type=int, default=200)
parser.add_argument("--summary-only", action="store_true", help="Omit per-skill records from output")
args = parser.parse_args()
roots: list[Path] = []
missing_roots: list[str] = []
for raw in args.root:
root = Path(os.path.expandvars(os.path.expanduser(raw))).resolve()
if root.is_dir():
roots.append(root)
else:
missing_roots.append(str(root))
records: list[dict[str, object]] = []
seen_paths: set[Path] = set()
for root in roots:
for skill_file in iter_skill_files(root):
resolved = skill_file.resolve()
if resolved in seen_paths:
continue
seen_paths.add(resolved)
records.append(skill_record(skill_file, root))
records.sort(key=lambda item: (str(item.get("name", "")), str(item.get("path", ""))))
by_name: dict[str, list[str]] = {}
for record in records:
name = str(record.get("name", ""))
if name:
by_name.setdefault(name, []).append(str(record["path"]))
duplicates = {name: paths for name, paths in sorted(by_name.items()) if len(paths) > 1}
overlaps = find_overlaps(records, args.overlap_threshold, args.max_overlaps)
report: dict[str, object] = {
"roots": [str(root) for root in roots],
"missing_roots": missing_roots,
"summary": {
"skills_found": len(records),
"duplicate_names": len(duplicates),
"trigger_overlaps": len(overlaps),
"skills_with_risk_indicators": sum(bool(r.get("risk_indicators")) for r in records),
},
"duplicates": duplicates,
"overlaps": overlaps,
"skills": records,
"notice": "Risk indicators and trigger overlap require manual review; they are not verdicts.",
}
if args.summary_only:
report.pop("skills")
if args.format == "markdown":
print(render_markdown(report))
else:
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Preview or create a project-local Skill Stack routing profile."""
from __future__ import annotations
import argparse
import json
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path
SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def validate_skill_name(value: str) -> str:
if not SKILL_NAME.fullmatch(value):
raise argparse.ArgumentTypeError(f"invalid Skill name: {value!r}")
return value
def parse_route(raw: str, active: set[str]) -> dict[str, object]:
if "=" not in raw:
raise ValueError(f"route must use 'intent=primary[,helper]': {raw!r}")
intent, raw_skills = raw.split("=", 1)
intent = intent.strip()
skills = [item.strip() for item in raw_skills.split(",") if item.strip()]
if not intent or not skills:
raise ValueError(f"route has no intent or Skill: {raw!r}")
invalid = [name for name in skills if not SKILL_NAME.fullmatch(name)]
if invalid:
raise ValueError("route contains invalid Skill names: " + ", ".join(invalid))
missing = [name for name in skills if name not in active]
if missing:
raise ValueError("route refers to Skills not listed with --skill: " + ", ".join(missing))
return {
"intent": intent,
"primary": skills[0],
"supporting": skills[1:],
}
def atomic_write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temporary = handle.name
os.replace(temporary, path)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", required=True, help="Project root")
parser.add_argument("--name", required=True, help="Plain profile name")
parser.add_argument("--skill", action="append", required=True, type=validate_skill_name, help="Active Skill; repeatable")
parser.add_argument("--route", action="append", default=[], help="Intent route: intent=primary[,helper]")
parser.add_argument("--strict", action="store_true", help="Do not search outside this profile automatically")
parser.add_argument("--apply", action="store_true", help="Write the profile; default is preview only")
parser.add_argument("--update", action="store_true", help="Replace an existing profile; requires --apply")
args = parser.parse_args()
if args.update and not args.apply:
raise SystemExit("--update requires --apply")
project = Path(os.path.expandvars(os.path.expanduser(args.project))).resolve()
if project.is_symlink() or not project.is_dir():
raise SystemExit(f"project is not a regular directory: {project}")
active_skills = list(dict.fromkeys(args.skill))
active_set = set(active_skills)
try:
routes = [parse_route(raw, active_set) for raw in args.route]
except ValueError as exc:
raise SystemExit(str(exc)) from exc
profile_path = project / ".codex" / "skill-stack.json"
if profile_path.exists() and not args.update:
raise SystemExit(f"profile already exists; refusing to overwrite: {profile_path}")
payload: dict[str, object] = {
"schema": 1,
"profile_name": args.name.strip(),
"project_root": str(project),
"generated_at": datetime.now(timezone.utc).isoformat(),
"active_skills": active_skills,
"routes": routes,
"routing": {
"preference": "profile-first",
"outside_search": "never" if args.strict else "only-for-uncovered-capabilities",
},
"privacy": "This profile stores routing preferences only. It contains no prompts, usage history, or feedback logs.",
"technical_note": "A profile guides routing. Actual hard scoping requires project-local Skill installation when supported by the client.",
}
if args.apply:
atomic_write_json(profile_path, payload)
status = "updated" if args.update else "created"
else:
status = "preview"
print(json.dumps({
"status": status,
"profile_path": str(profile_path),
"profile": payload,
}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Render a safe, dependency-free SVG recommendation card from JSON."""
from __future__ import annotations
import argparse
import html
import json
import os
import tempfile
import textwrap
from pathlib import Path
STATUS_COLORS = {
"available": ("#0f766e", "#ccfbf1"),
"recommended": ("#1d4ed8", "#dbeafe"),
"optional": ("#7c3aed", "#ede9fe"),
"not-recommended": ("#b45309", "#fef3c7"),
"verified": ("#15803d", "#dcfce7"),
}
def clean_text(value: object, limit: int) -> str:
text = " ".join(str(value or "").split())
return text[:limit]
def wrap(value: object, width: int, limit: int) -> list[str]:
text = clean_text(value, limit)
return textwrap.wrap(text, width=width, break_long_words=False) or [""]
def atomic_write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
handle.write(content)
temporary = handle.name
os.replace(temporary, path)
def validate(payload: object) -> dict[str, object]:
if not isinstance(payload, dict):
raise ValueError("card input must be a JSON object")
if not clean_text(payload.get("title"), 120):
raise ValueError("title is required")
if not clean_text(payload.get("goal"), 400):
raise ValueError("goal is required")
skills = payload.get("skills")
if not isinstance(skills, list) or not skills:
raise ValueError("skills must be a non-empty list")
if len(skills) > 8:
raise ValueError("a shareable card supports at most 8 Skills")
for index, skill in enumerate(skills):
if not isinstance(skill, dict) or not clean_text(skill.get("name"), 80):
raise ValueError(f"skills[{index}].name is required")
return payload
def text_element(x: int, y: int, text: object, size: int, color: str, weight: int = 400) -> str:
return (
f'<text x="{x}" y="{y}" font-family="Inter, ui-sans-serif, system-ui, sans-serif" '
f'font-size="{size}" font-weight="{weight}" fill="{color}">{html.escape(str(text))}</text>'
)
def render(payload: dict[str, object]) -> str:
width = 1200
title = clean_text(payload.get("title"), 120)
goal_lines = wrap(payload.get("goal"), 82, 400)[:3]
skills = payload["skills"]
warnings = payload.get("boundaries", [])
if not isinstance(warnings, list):
warnings = [warnings]
warning_lines: list[str] = []
for warning in warnings[:3]:
warning_lines.extend(wrap(warning, 92, 240)[:2])
height = 250 + len(goal_lines) * 34 + len(skills) * 92 + max(1, len(warning_lines)) * 30 + 110
parts = [
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
f'<title id="title">{html.escape(title)}</title>',
f'<desc id="desc">{html.escape(clean_text(payload.get("goal"), 400))}</desc>',
'<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#07152f"/><stop offset="1" stop-color="#123b5d"/></linearGradient></defs>',
f'<rect width="{width}" height="{height}" rx="36" fill="url(#bg)"/>',
'<circle cx="1080" cy="90" r="160" fill="#38bdf8" opacity="0.10"/>',
'<circle cx="1120" cy="30" r="80" fill="#a78bfa" opacity="0.12"/>',
text_element(64, 72, "AGENT SKILL STACK", 22, "#7dd3fc", 700),
text_element(64, 122, title, 38, "#ffffff", 750),
]
y = 166
for line in goal_lines:
parts.append(text_element(64, y, line, 24, "#dbeafe", 400))
y += 34
y += 22
for skill in skills:
name = clean_text(skill.get("name"), 80)
role = clean_text(skill.get("role"), 180)
status = clean_text(skill.get("status"), 40).lower() or "recommended"
foreground, background = STATUS_COLORS.get(status, ("#334155", "#e2e8f0"))
parts.extend([
f'<rect x="56" y="{y}" width="1088" height="72" rx="18" fill="#ffffff" opacity="0.96"/>',
text_element(84, y + 31, name, 24, "#0f172a", 700),
text_element(84, y + 57, role, 18, "#475569", 400),
f'<rect x="956" y="{y + 18}" width="160" height="36" rx="18" fill="{background}"/>',
text_element(976, y + 43, status.replace("-", " ").title(), 16, foreground, 700),
])
y += 92
parts.append(text_element(64, y + 4, "SAFETY BOUNDARY", 18, "#7dd3fc", 700))
y += 34
if not warning_lines:
warning_lines = ["No additional boundary recorded."]
for line in warning_lines:
parts.append(text_element(72, y, f"{line}", 19, "#e2e8f0", 400))
y += 30
verified = clean_text(payload.get("verified"), 40) or "not recorded"
footer = clean_text(payload.get("footer"), 120) or "Minimal. Audited. Project-specific."
parts.extend([
f'<line x1="64" y1="{height - 78}" x2="1136" y2="{height - 78}" stroke="#7dd3fc" opacity="0.25"/>',
text_element(64, height - 38, footer, 17, "#bae6fd", 500),
text_element(934, height - 38, f"Verified: {verified}", 17, "#bae6fd", 500),
"</svg>",
])
return "\n".join(parts) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, help="JSON card definition")
parser.add_argument("--output", required=True, help="SVG destination")
parser.add_argument("--force", action="store_true", help="Replace an existing output file")
args = parser.parse_args()
source = Path(args.input).expanduser().resolve()
output = Path(args.output).expanduser().resolve()
if not source.is_file():
raise SystemExit(f"input is not a file: {source}")
if output.exists() and not args.force:
raise SystemExit(f"refusing to overwrite existing output: {output}")
if output.suffix.lower() != ".svg":
raise SystemExit("output must use the .svg extension")
try:
payload = validate(json.loads(source.read_text(encoding="utf-8")))
svg = render(payload)
except (OSError, json.JSONDecodeError, ValueError) as exc:
raise SystemExit(str(exc)) from exc
atomic_write(output, svg)
print(json.dumps({"status": "created", "output": str(output)}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,273 @@
#!/usr/bin/env python3
"""Build and search a local, read-only index of installed agent Skills."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from inventory_skills import iter_skill_files, parse_frontmatter, tokenize
QUERY_EXPANSIONS = [
(
("技能组合", "技能栈", "技能包", "配齐", "skill stack", "一套skills", "一套 skills"),
"agent-skill-stack build curate skill stack capability workflow project profile local index compare conflicts install 组合 技能栈 能力 工作流 项目",
),
(
("找一个", "找个", "找一款", "find a skill", "有没有skill", "有没有 skill", "有没有能"),
"find-skills find skills discover install common capability 查找 单个 技能",
),
(
("去ai", "ai味", "humanize", "natural writing", "文风", "自然一点"),
"humanizer humanize writing rewrite natural style tone voice 文案 改写 自然 文风",
),
(
("事实核查", "fact check", "引用", "citation", "可信"),
"fact check verify evidence citation grounded accuracy 核查 引用 证据 准确",
),
(
("合规", "compliance", "版权", "copyright", "规则"),
"compliance policy copyright legal safety audit 合规 版权 规则 审核",
),
(
("调研", "research", "对标", "竞品", "搜集"),
"research search collect compare benchmark competitor evidence 调研 搜索 收集 对标 竞品",
),
(
("发布", "publish", "定时", "schedule"),
"publish schedule post upload automation approval 发布 定时 上传 自动化 审批",
),
(
("整理", "入库", "知识库", "organize", "knowledge base"),
"organize knowledge base notes database deduplicate structure 整理 入库 知识库 去重 结构化",
),
]
def utc_iso(timestamp: float | None = None) -> str:
moment = datetime.fromtimestamp(timestamp, tz=timezone.utc) if timestamp is not None else datetime.now(timezone.utc)
return moment.isoformat()
def sha256_file(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 atomic_write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temporary = handle.name
os.replace(temporary, path)
def infer_scope(skill_file: Path, project_root: Path | None) -> str:
if project_root is not None:
try:
skill_file.relative_to(project_root)
return "project"
except ValueError:
pass
return "global"
def build_record(skill_file: Path, root: Path, project_root: Path | None) -> dict[str, object]:
text = skill_file.read_text(encoding="utf-8", errors="replace")
metadata, issues = parse_frontmatter(text[:300_000])
name = metadata.get("name", "")
description = metadata.get("description", "")
headings = [
re.sub(r"\s+#+$", "", heading).strip()
for heading in re.findall(r"^#{1,3}\s+(.+)$", text, flags=re.MULTILINE)
][:40]
capability_terms = sorted(tokenize(" ".join([name, description, *headings])))
if name and skill_file.parent.name != name:
issues.append(f"directory name '{skill_file.parent.name}' differs from skill name '{name}'")
fingerprint = sha256_file(skill_file)
summary = re.sub(r"\s+", " ", description).strip()
if len(summary) > 360:
summary = summary[:357].rstrip() + "..."
return {
"id": f"{name or 'invalid'}:{fingerprint[:12]}",
"name": name,
"display_name": name.replace("-", " ").strip().title() if name else "Invalid Skill",
"summary": summary,
"scope": infer_scope(skill_file, project_root),
"aliases": capability_terms[:80],
"capability_terms": capability_terms,
"headings": headings,
"last_local_change": utc_iso(skill_file.stat().st_mtime),
"issues": issues,
"technical": {
"source_root": str(root),
"path": str(skill_file.parent),
"skill_file_fingerprint": fingerprint,
},
}
def build_index(args: argparse.Namespace) -> int:
project_root = Path(args.project_root).expanduser().resolve() if args.project_root else None
roots: list[Path] = []
missing: list[str] = []
for raw in args.root:
root = Path(os.path.expandvars(os.path.expanduser(raw))).resolve()
if root.is_dir():
roots.append(root)
else:
missing.append(str(root))
records: list[dict[str, object]] = []
seen: set[Path] = set()
for root in roots:
for skill_file in iter_skill_files(root):
resolved = skill_file.resolve()
if resolved in seen:
continue
seen.add(resolved)
records.append(build_record(skill_file, root, project_root))
records.sort(key=lambda item: (str(item.get("name", "")), str(item["technical"]["path"])))
names: dict[str, list[str]] = {}
for record in records:
if record["name"]:
names.setdefault(str(record["name"]), []).append(str(record["id"]))
duplicates = {name: ids for name, ids in sorted(names.items()) if len(ids) > 1}
output = Path(os.path.expandvars(os.path.expanduser(args.output))).resolve()
payload: dict[str, object] = {
"schema": 1,
"generated_at": utc_iso(),
"roots": [str(root) for root in roots],
"missing_roots": missing,
"skills": records,
"duplicates": duplicates,
"privacy": "This index stores Skill metadata only. It contains no prompts, usage history, or routing feedback.",
}
atomic_write_json(output, payload)
print(json.dumps({
"status": "built",
"output": str(output),
"skills_indexed": len(records),
"duplicate_names": len(duplicates),
"missing_roots": missing,
}, ensure_ascii=False, indent=2))
return 0
def expanded_query(query: str) -> str:
lower = query.lower()
additions = [terms for triggers, terms in QUERY_EXPANSIONS if any(trigger in lower for trigger in triggers)]
return " ".join([query, *additions])
def score_record(
record: dict[str, object],
query: str,
direct_tokens: set[str],
expanded_tokens: set[str],
) -> tuple[float, list[str]]:
name = str(record.get("name", "")).lower()
summary = str(record.get("summary", "")).lower()
aliases = set(str(item) for item in record.get("aliases", []))
capability_terms = set(str(item) for item in record.get("capability_terms", []))
lower_query = query.lower().strip()
record_tokens = aliases | capability_terms
direct_matched = sorted(direct_tokens & record_tokens)
helper_matched = sorted((expanded_tokens - direct_tokens) & record_tokens)
matched = [*direct_matched, *helper_matched]
# What the user actually said must outrank generic query-expansion terms.
score = float(len(direct_matched) * 4 + len(helper_matched))
if lower_query and lower_query == name:
score += 20
elif lower_query and lower_query in name:
score += 10
if lower_query and lower_query in summary:
score += 8
if name and name in direct_tokens:
score += 18
elif name and name in expanded_tokens:
score += 14
if direct_tokens:
score += 10 * len(direct_matched) / len(direct_tokens)
if score > 0 and record.get("scope") == "project":
score += 1
if record.get("issues"):
score -= 2
return score, matched
def search_index(args: argparse.Namespace) -> int:
index_path = Path(os.path.expandvars(os.path.expanduser(args.index))).resolve()
payload = json.loads(index_path.read_text(encoding="utf-8"))
expanded = expanded_query(args.query)
direct_tokens = tokenize(args.query)
expanded_tokens = tokenize(expanded)
results: list[dict[str, object]] = []
for record in payload.get("skills", []):
score, matched = score_record(record, args.query, direct_tokens, expanded_tokens)
if score <= 0:
continue
results.append({
"name": record.get("name"),
"display_name": record.get("display_name"),
"summary": record.get("summary"),
"scope": record.get("scope"),
"score": round(score, 3),
"matched_terms": matched[:20],
"issues": record.get("issues", []),
"technical": record.get("technical", {}),
})
results.sort(key=lambda item: (-float(item["score"]), str(item["name"])))
results = results[:args.limit]
if args.format == "simple":
for position, result in enumerate(results, start=1):
scope = "项目内" if result["scope"] == "project" else "全局"
print(f"{position}. {result['display_name']}{scope})— {result['summary']}")
else:
print(json.dumps({
"query": args.query,
"expanded_query": expanded,
"results": results,
"notice": "Search scores are retrieval hints, not quality or installation scores.",
}, ensure_ascii=False, indent=2))
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", help="Build or refresh a local Skill index")
build.add_argument("--root", action="append", required=True, help="Skill root; repeatable")
build.add_argument("--output", required=True, help="Index JSON output path")
build.add_argument("--project-root", help="Optional project root used to mark project-scoped Skills")
build.set_defaults(handler=build_index)
search = subparsers.add_parser("search", help="Search a previously built local Skill index")
search.add_argument("--index", required=True, help="Index JSON path")
search.add_argument("--query", required=True, help="Natural-language capability query")
search.add_argument("--limit", type=int, default=10)
search.add_argument("--format", choices=("json", "simple"), default="json")
search.set_defaults(handler=search_index)
args = parser.parse_args()
return int(args.handler(args))
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Preview or install already downloaded and audited skill directories without overwrite."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import tempfile
from datetime import datetime, timezone
from pathlib import Path
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
def parse_name(skill_file: Path) -> str:
lines = skill_file.read_text(encoding="utf-8", errors="strict").splitlines()
if not lines or lines[0].strip() != "---":
raise ValueError(f"{skill_file}: missing opening frontmatter delimiter")
end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
if end is None:
raise ValueError(f"{skill_file}: missing closing frontmatter delimiter")
for line in lines[1:end]:
match = re.match(r"^name:\s*([^#]+?)\s*$", line)
if match:
name = match.group(1).strip().strip('"\'')
if len(name) > 63 or not NAME_RE.fullmatch(name):
raise ValueError(f"{skill_file}: invalid skill name {name!r}")
return name
raise ValueError(f"{skill_file}: missing name")
def collect_files(source: Path) -> list[Path]:
files: list[Path] = []
for current, dirs, filenames in os.walk(source, followlinks=False):
current_path = Path(current)
for dirname in dirs:
path = current_path / dirname
if path.is_symlink():
raise ValueError(f"symlinked directories are not allowed: {path}")
for filename in filenames:
path = current_path / filename
if path.is_symlink():
raise ValueError(f"symlinked files are not allowed: {path}")
if not path.is_file():
raise ValueError(f"unsupported filesystem entry: {path}")
files.append(path)
return sorted(files, key=lambda path: str(path.relative_to(source)))
def sha256_file(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 source_record(source: Path, dest: Path) -> dict[str, object]:
if source.is_symlink() or not source.is_dir():
raise ValueError(f"source is not a regular directory: {source}")
skill_file = source / "SKILL.md"
if not skill_file.is_file():
raise ValueError(f"source has no SKILL.md: {source}")
name = parse_name(skill_file)
files = collect_files(source)
file_records = [
{
"path": str(path.relative_to(source)),
"sha256": sha256_file(path),
"bytes": path.stat().st_size,
}
for path in files
]
aggregate = hashlib.sha256()
for item in file_records:
aggregate.update(str(item["path"]).encode("utf-8"))
aggregate.update(str(item["sha256"]).encode("ascii"))
return {
"name": name,
"source": str(source),
"target": str(dest / name),
"content_sha256": aggregate.hexdigest(),
"file_count": len(file_records),
"files": file_records,
}
def atomic_write_json(path: Path, payload: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temp_name = handle.name
os.replace(temp_name, path)
def apply_install(records: list[dict[str, object]], dest: Path) -> list[str]:
dest.mkdir(parents=True, exist_ok=True)
staging = Path(tempfile.mkdtemp(prefix=".agent-skill-stack-", dir=dest))
created: list[str] = []
try:
for record in records:
source = Path(str(record["source"]))
staged = staging / str(record["name"])
shutil.copytree(source, staged, symlinks=False)
if parse_name(staged / "SKILL.md") != record["name"]:
raise RuntimeError(f"staged validation failed for {record['name']}")
for record in records:
staged = staging / str(record["name"])
target = Path(str(record["target"]))
os.replace(staged, target)
created.append(str(target))
return created
finally:
shutil.rmtree(staging, ignore_errors=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", action="append", required=True, help="Audited local skill directory; repeatable")
parser.add_argument("--dest", required=True, help="Destination skill root")
parser.add_argument("--manifest", required=True, help="Path for the lock/preview manifest")
parser.add_argument("--apply", action="store_true", help="Copy after validation; default is dry-run")
parser.add_argument(
"--record-existing",
action="store_true",
help="Record a lock manifest when each source is already its exact destination",
)
args = parser.parse_args()
if args.apply and args.record_existing:
raise SystemExit("--apply and --record-existing are mutually exclusive")
dest = Path(os.path.expandvars(os.path.expanduser(args.dest))).resolve()
manifest = Path(os.path.expandvars(os.path.expanduser(args.manifest))).resolve()
sources = [Path(os.path.expandvars(os.path.expanduser(raw))).resolve() for raw in args.source]
records = [source_record(source, dest) for source in sources]
names = [str(record["name"]) for record in records]
if len(names) != len(set(names)):
raise SystemExit("duplicate skill names in selected sources")
existing = [str(record["target"]) for record in records if Path(str(record["target"])).exists()]
if args.record_existing:
mismatched = [
str(record["name"])
for record in records
if Path(str(record["source"])).resolve() != Path(str(record["target"])).resolve()
]
if mismatched:
raise SystemExit("--record-existing requires source to equal target for: " + ", ".join(mismatched))
elif existing:
raise SystemExit("refusing to overwrite existing destinations: " + ", ".join(existing))
payload: dict[str, object] = {
"schema": 1,
"generated_at": datetime.now(timezone.utc).isoformat(),
"mode": "record-existing" if args.record_existing else ("apply" if args.apply else "dry-run"),
"destination": str(dest),
"skills": records,
"created": [],
"notice": "Sources must be downloaded and audited before using this installer. Existing targets are never overwritten.",
}
if args.record_existing:
payload["status"] = "recorded"
elif args.apply:
payload["created"] = apply_install(records, dest)
payload["status"] = "installed"
else:
payload["status"] = "planned"
atomic_write_json(manifest, payload)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())