agent-skill-sync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_skill_sync-0.1.0.dist-info/METADATA +182 -0
- agent_skill_sync-0.1.0.dist-info/RECORD +17 -0
- agent_skill_sync-0.1.0.dist-info/WHEEL +5 -0
- agent_skill_sync-0.1.0.dist-info/entry_points.txt +2 -0
- agent_skill_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_skill_sync-0.1.0.dist-info/top_level.txt +1 -0
- skillsync/__init__.py +11 -0
- skillsync/classifier.py +161 -0
- skillsync/cli.py +218 -0
- skillsync/config.py +185 -0
- skillsync/differ.py +98 -0
- skillsync/frontmatter.py +260 -0
- skillsync/model.py +78 -0
- skillsync/report.py +134 -0
- skillsync/resolve.py +114 -0
- skillsync/scanner.py +90 -0
- skillsync/sync.py +155 -0
skillsync/report.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Report generation: markdown index + JSON inventory.
|
|
2
|
+
|
|
3
|
+
The markdown output is designed to drop straight into a ``local-tool-reuse``
|
|
4
|
+
style index skill: one section per category, a summary table, and per-skill
|
|
5
|
+
reasons so a human (or an agent) can act on it without re-deriving anything.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .differ import sync_state
|
|
16
|
+
from .model import CATEGORIES, ScanResult, Skill
|
|
17
|
+
|
|
18
|
+
_CATEGORY_TITLES = {
|
|
19
|
+
"A": "A — 可直接复制使用(无平台绑定,依赖齐全)",
|
|
20
|
+
"B": "B — 可迁移,但缺外部依赖(按需补齐)",
|
|
21
|
+
"C": "C — 方法论可复用,文本须为目标平台改写",
|
|
22
|
+
"D": "D — 平台私有,不要迁移",
|
|
23
|
+
"?": "? — 未分类",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_CATEGORY_TITLES_EN = {
|
|
27
|
+
"A": "A — portable (no platform bindings, all deps present)",
|
|
28
|
+
"B": "B — portable but missing external dependencies",
|
|
29
|
+
"C": "C — methodology reusable, text must be rewritten for the target",
|
|
30
|
+
"D": "D — platform-private, do not migrate",
|
|
31
|
+
"?": "? — unclassified",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_json(result: ScanResult) -> str:
|
|
36
|
+
payload = {
|
|
37
|
+
"generated_at": _dt.datetime.now().isoformat(timespec="seconds"),
|
|
38
|
+
"roots": [{"label": lbl, "path": str(p)} for lbl, p in result.roots_scanned],
|
|
39
|
+
"errors": result.errors,
|
|
40
|
+
"counts": dict(Counter(s.category for s in result.skills)),
|
|
41
|
+
"skills": [
|
|
42
|
+
{
|
|
43
|
+
"name": s.name,
|
|
44
|
+
"source": s.source,
|
|
45
|
+
"path": str(s.path),
|
|
46
|
+
"category": s.category,
|
|
47
|
+
"category_reason": s.category_reason,
|
|
48
|
+
"missing_deps": s.missing_deps,
|
|
49
|
+
"sync": sync_state(s),
|
|
50
|
+
"synced_to": str(s.synced_to) if s.synced_to else None,
|
|
51
|
+
"description": s.description[:200],
|
|
52
|
+
"warnings": s.parse_warnings,
|
|
53
|
+
}
|
|
54
|
+
for s in sorted(result.skills, key=lambda x: (x.source, x.name))
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_markdown(result: ScanResult, *, lang: str = "zh") -> str:
|
|
61
|
+
titles = _CATEGORY_TITLES if lang == "zh" else _CATEGORY_TITLES_EN
|
|
62
|
+
by_cat = result.by_category()
|
|
63
|
+
counts = Counter(s.category for s in result.skills)
|
|
64
|
+
sync_counts = Counter(sync_state(s) for s in result.skills)
|
|
65
|
+
|
|
66
|
+
lines: list[str] = []
|
|
67
|
+
stamp = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
68
|
+
lines.append("# Skill 迁移索引(自动生成)" if lang == "zh" else "# Skill migration index (generated)")
|
|
69
|
+
lines.append("")
|
|
70
|
+
lines.append(
|
|
71
|
+
f"> 由 `skillsync` 生成于 {stamp} — 不要手工编辑此文件。"
|
|
72
|
+
if lang == "zh"
|
|
73
|
+
else f"> Generated by `skillsync` at {stamp} — do not edit by hand."
|
|
74
|
+
)
|
|
75
|
+
lines.append("")
|
|
76
|
+
|
|
77
|
+
lines.append("## 概览" if lang == "zh" else "## Overview")
|
|
78
|
+
lines.append("")
|
|
79
|
+
lines.append("| 指标 | 数量 |")
|
|
80
|
+
lines.append("|---|---|")
|
|
81
|
+
lines.append(f"| 扫描到 skill 总数 | {len(result.skills)} |")
|
|
82
|
+
for cat in CATEGORIES:
|
|
83
|
+
lines.append(f"| {cat} 类 | {counts.get(cat, 0)} |")
|
|
84
|
+
lines.append(f"| 已同步(identical) | {sync_counts.get('identical', 0)} |")
|
|
85
|
+
lines.append(f"| 内容漂移(drifted) | {sync_counts.get('drifted', 0)} |")
|
|
86
|
+
lines.append(f"| 目标缺失(missing) | {sync_counts.get('missing', 0)} |")
|
|
87
|
+
lines.append("")
|
|
88
|
+
|
|
89
|
+
lines.append("## 扫描根目录" if lang == "zh" else "## Roots scanned")
|
|
90
|
+
lines.append("")
|
|
91
|
+
for label, path in result.roots_scanned:
|
|
92
|
+
lines.append(f"- `{label}` → `{path}`")
|
|
93
|
+
if result.errors:
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append("### 警告" if lang == "zh" else "### Warnings")
|
|
96
|
+
for err in result.errors:
|
|
97
|
+
lines.append(f"- {err}")
|
|
98
|
+
lines.append("")
|
|
99
|
+
|
|
100
|
+
for cat in (*CATEGORIES, "?"):
|
|
101
|
+
skills = by_cat.get(cat) or []
|
|
102
|
+
if not skills:
|
|
103
|
+
continue
|
|
104
|
+
lines.append(f"## {titles[cat]}({len(skills)})")
|
|
105
|
+
lines.append("")
|
|
106
|
+
lines.append("| skill | 来源 | 同步状态 | 判定依据 / 缺失依赖 |")
|
|
107
|
+
lines.append("|---|---|---|---|")
|
|
108
|
+
for s in sorted(skills, key=lambda x: x.name):
|
|
109
|
+
reason = s.category_reason.replace("|", "\\|")
|
|
110
|
+
if s.missing_deps:
|
|
111
|
+
reason += " — 缺: " + ", ".join(f"`{d}`" for d in s.missing_deps)
|
|
112
|
+
state = sync_state(s)
|
|
113
|
+
mark = {"identical": "✅", "drifted": "⚠️ 漂移", "missing": "—"}.get(state, state)
|
|
114
|
+
lines.append(f"| `{s.name}` | {s.source} | {mark} | {reason} |")
|
|
115
|
+
lines.append("")
|
|
116
|
+
|
|
117
|
+
drifted = [s for s in result.skills if sync_state(s) == "drifted"]
|
|
118
|
+
if drifted:
|
|
119
|
+
lines.append("## 漂移明细(源与目标内容不一致)" if lang == "zh" else "## Drift details")
|
|
120
|
+
lines.append("")
|
|
121
|
+
for s in sorted(drifted, key=lambda x: x.name):
|
|
122
|
+
lines.append(f"- `{s.name}`: 源 `{s.path}` ≠ 目标 `{s.synced_to}`")
|
|
123
|
+
lines.append("")
|
|
124
|
+
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_outputs(result: ScanResult, out_dir: Path, *, lang: str = "zh") -> list[Path]:
|
|
129
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
md_path = out_dir / "INDEX.md"
|
|
131
|
+
json_path = out_dir / "inventory.json"
|
|
132
|
+
md_path.write_text(to_markdown(result, lang=lang), encoding="utf-8")
|
|
133
|
+
json_path.write_text(to_json(result), encoding="utf-8")
|
|
134
|
+
return [md_path, json_path]
|
skillsync/resolve.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Cross-source resolution: one logical skill may exist in several roots.
|
|
2
|
+
|
|
3
|
+
After scanning, many skill *names* appear in multiple source roots (Codex,
|
|
4
|
+
Qoder-CN...). ``resolve()`` collapses them into a single authoritative skill:
|
|
5
|
+
|
|
6
|
+
* **identical** — every copy has the same content (ignoring line endings).
|
|
7
|
+
Keep one representative, record the rest as ``duplicates``.
|
|
8
|
+
* **shadowed** — copies differ. The highest-priority source (per
|
|
9
|
+
``source_priority``) is the *canonical* version and is kept; lower-priority
|
|
10
|
+
copies are recorded as ``shadowed`` (stale duplicates superseded by the
|
|
11
|
+
canonical one). They are dropped from the main list so they cannot create
|
|
12
|
+
false drift signals or be synced back over the newer version.
|
|
13
|
+
|
|
14
|
+
Grouping uses the **expected target directory name**, not the bare skill name,
|
|
15
|
+
so that distinct skills that merely share a base name — e.g.
|
|
16
|
+
``agent-skills:test-driven-development`` (target ``test-driven-development``)
|
|
17
|
+
and ``superpowers:test-driven-development`` (target
|
|
18
|
+
``superpowers-test-driven-development``) — are never merged.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .differ import DEFAULT_PREFIXES
|
|
24
|
+
from .model import Skill
|
|
25
|
+
|
|
26
|
+
DEFAULT_PRIORITY = ("codex-marketplace", "codex", "qoder-cn", "qoder", "claude")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _norm(data: bytes) -> bytes:
|
|
30
|
+
return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _content(skill: Skill) -> bytes:
|
|
34
|
+
try:
|
|
35
|
+
return _norm(skill.path.read_bytes())
|
|
36
|
+
except OSError:
|
|
37
|
+
return b""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def expected_target_name(skill_name: str) -> str:
|
|
41
|
+
"""Map a (possibly qualified) source skill name to its target dir name.
|
|
42
|
+
|
|
43
|
+
``superpowers:X`` → ``superpowers-X`` (the WorkBuddy convention that keeps
|
|
44
|
+
the superpowers family from clashing with same-named skills); any other
|
|
45
|
+
qualified ``plugin:X`` and every bare ``X`` → ``X``.
|
|
46
|
+
"""
|
|
47
|
+
if ":" not in skill_name:
|
|
48
|
+
return skill_name
|
|
49
|
+
plugin, skill_part = skill_name.split(":", 1)
|
|
50
|
+
if f"{plugin}-" in DEFAULT_PREFIXES:
|
|
51
|
+
return f"{plugin}-{skill_part}"
|
|
52
|
+
return skill_part
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _rank(source: str, priority: tuple[str, ...]) -> int:
|
|
56
|
+
try:
|
|
57
|
+
return priority.index(source)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return len(priority)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _group_key(skill: Skill) -> str:
|
|
63
|
+
"""Group skills by the target slot they map onto.
|
|
64
|
+
|
|
65
|
+
Prefer the slot the differ actually matched (``synced_to``), so a bare
|
|
66
|
+
qoder-cn copy that owns a prefixed target dir (e.g. bare
|
|
67
|
+
``finishing-a-development-branch`` → ``superpowers-finishing-a-development-branch``)
|
|
68
|
+
groups together with its qualified codex counterpart. Fall back to the
|
|
69
|
+
expected target name for skills with no target slot yet.
|
|
70
|
+
"""
|
|
71
|
+
if skill.synced_to is not None:
|
|
72
|
+
return f"@{skill.synced_to.parent}"
|
|
73
|
+
return expected_target_name(skill.name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve(
|
|
77
|
+
skills: list[Skill],
|
|
78
|
+
*,
|
|
79
|
+
source_priority: tuple[str, ...] = DEFAULT_PRIORITY,
|
|
80
|
+
) -> tuple[list[Skill], list[tuple[str, list[str]]], list[tuple[str, str, list[str]]]]:
|
|
81
|
+
"""Deduplicate skills across sources.
|
|
82
|
+
|
|
83
|
+
Returns ``(resolved, duplicates, shadowed)``:
|
|
84
|
+
|
|
85
|
+
* ``resolved`` — the canonical skill list (one entry per target slot).
|
|
86
|
+
* ``duplicates`` — ``(target_name, [source labels])`` collapsed as identical.
|
|
87
|
+
* ``shadowed`` — ``(target_name, winner_source, [loser sources])`` where a
|
|
88
|
+
lower-priority stale copy was superseded by a newer canonical version.
|
|
89
|
+
"""
|
|
90
|
+
groups: dict[str, list[Skill]] = {}
|
|
91
|
+
for skill in skills:
|
|
92
|
+
groups.setdefault(_group_key(skill), []).append(skill)
|
|
93
|
+
|
|
94
|
+
resolved: list[Skill] = []
|
|
95
|
+
duplicates: list[tuple[str, list[str]]] = []
|
|
96
|
+
shadowed: list[tuple[str, str, list[str]]] = []
|
|
97
|
+
|
|
98
|
+
for target_name, group in groups.items():
|
|
99
|
+
group.sort(key=lambda s: (_rank(s.source, source_priority), str(s.path)))
|
|
100
|
+
if len(group) == 1:
|
|
101
|
+
resolved.append(group[0])
|
|
102
|
+
continue
|
|
103
|
+
contents = [_content(s) for s in group]
|
|
104
|
+
if len(set(contents)) == 1:
|
|
105
|
+
resolved.append(group[0])
|
|
106
|
+
duplicates.append((target_name, [s.source for s in group]))
|
|
107
|
+
else:
|
|
108
|
+
winner = group[0]
|
|
109
|
+
resolved.append(winner)
|
|
110
|
+
losers = group[1:]
|
|
111
|
+
shadowed.append((target_name, winner.source, [s.source for s in losers]))
|
|
112
|
+
|
|
113
|
+
resolved.sort(key=lambda s: (s.source, expected_target_name(s.name)))
|
|
114
|
+
return resolved, duplicates, shadowed
|
skillsync/scanner.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Scanner: walk configured roots and produce :class:`Skill` records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import DEFAULT_GLOB, Config, SourceRoot
|
|
8
|
+
from .frontmatter import parse_file
|
|
9
|
+
from .model import ScanResult, Skill
|
|
10
|
+
|
|
11
|
+
_SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _iter_skill_files(root: SourceRoot) -> list[Path]:
|
|
15
|
+
if not root.path.is_dir():
|
|
16
|
+
return []
|
|
17
|
+
if root.glob == DEFAULT_GLOB:
|
|
18
|
+
# Non-recursive: only direct child directories.
|
|
19
|
+
files = []
|
|
20
|
+
for child in sorted(root.path.iterdir()):
|
|
21
|
+
if child.is_dir() and child.name not in _SKIP_DIRS:
|
|
22
|
+
candidate = child / "SKILL.md"
|
|
23
|
+
if candidate.is_file():
|
|
24
|
+
files.append(candidate)
|
|
25
|
+
return files
|
|
26
|
+
files = []
|
|
27
|
+
seen: set[Path] = set()
|
|
28
|
+
for match in root.path.glob(root.glob):
|
|
29
|
+
rel_parts = match.relative_to(root.path).parts[:-1]
|
|
30
|
+
if any(part in _SKIP_DIRS for part in rel_parts):
|
|
31
|
+
continue
|
|
32
|
+
resolved = match.resolve()
|
|
33
|
+
if resolved in seen:
|
|
34
|
+
continue
|
|
35
|
+
seen.add(resolved)
|
|
36
|
+
files.append(match)
|
|
37
|
+
return sorted(files)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _qualified_name(root: SourceRoot, path: Path) -> str:
|
|
41
|
+
"""Derive a readable skill name.
|
|
42
|
+
|
|
43
|
+
For marketplace-style roots (``**/skills/<name>/SKILL.md``) the name is the
|
|
44
|
+
directory. For plugin caches we keep a ``plugin:skill`` qualified name when
|
|
45
|
+
the layout allows it.
|
|
46
|
+
"""
|
|
47
|
+
rel = path.relative_to(root.path)
|
|
48
|
+
parts = rel.parts[:-1] # drop SKILL.md
|
|
49
|
+
if not parts:
|
|
50
|
+
return root.path.name
|
|
51
|
+
name = parts[-1]
|
|
52
|
+
# Heuristic: .../<plugin>/<version>/skills/<name> → plugin:name
|
|
53
|
+
if "skills" in parts:
|
|
54
|
+
idx = parts.index("skills")
|
|
55
|
+
if idx > 0:
|
|
56
|
+
plugin = parts[idx - 2] if idx >= 2 else parts[idx - 1]
|
|
57
|
+
if plugin != "skills":
|
|
58
|
+
return f"{plugin}:{name}"
|
|
59
|
+
return name
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def scan(config: Config) -> ScanResult:
|
|
63
|
+
result = ScanResult()
|
|
64
|
+
for root in config.sources:
|
|
65
|
+
result.roots_scanned.append((root.label, root.path))
|
|
66
|
+
if not root.exists():
|
|
67
|
+
result.errors.append(f"source root missing: {root.label} ({root.path})")
|
|
68
|
+
continue
|
|
69
|
+
for skill_file in _iter_skill_files(root):
|
|
70
|
+
try:
|
|
71
|
+
doc = parse_file(skill_file)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
result.errors.append(f"unreadable: {skill_file}: {exc}")
|
|
74
|
+
continue
|
|
75
|
+
data = doc.data or {}
|
|
76
|
+
name_in_meta = data.get("name")
|
|
77
|
+
skill = Skill(
|
|
78
|
+
name=_qualified_name(root, skill_file),
|
|
79
|
+
path=skill_file,
|
|
80
|
+
source=root.label,
|
|
81
|
+
root=root.path,
|
|
82
|
+
frontmatter=data,
|
|
83
|
+
description=str(data.get("description") or "").strip(),
|
|
84
|
+
parse_warnings=doc.parse_warnings,
|
|
85
|
+
)
|
|
86
|
+
if isinstance(name_in_meta, str) and name_in_meta and ":" not in skill.name:
|
|
87
|
+
# Prefer the declared name when it looks sane.
|
|
88
|
+
skill.name = name_in_meta
|
|
89
|
+
result.skills.append(skill)
|
|
90
|
+
return result
|
skillsync/sync.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Sync: copy category-A (and optionally B) skills into the target root.
|
|
2
|
+
|
|
3
|
+
Safety rules, all mandatory:
|
|
4
|
+
|
|
5
|
+
* Never overwrite by default. ``--force`` is required to replace a drifted
|
|
6
|
+
copy, and the replaced directory is first moved to ``.skillsync-backup/``
|
|
7
|
+
inside the target root (never deleted).
|
|
8
|
+
* Skills with large private payloads (``.venv``, ``.env``, node_modules) are
|
|
9
|
+
excluded from the copy — secrets must not travel silently.
|
|
10
|
+
* Dry-run (``--dry-run``) prints the exact plan and touches nothing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .differ import sync_state
|
|
20
|
+
from .model import Skill
|
|
21
|
+
from .resolve import DEFAULT_PRIORITY
|
|
22
|
+
|
|
23
|
+
_EXCLUDE_NAMES = {".venv", "venv", "node_modules", "__pycache__", ".env", ".git"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _priority_rank(source: str) -> int:
|
|
27
|
+
try:
|
|
28
|
+
return DEFAULT_PRIORITY.index(source)
|
|
29
|
+
except ValueError:
|
|
30
|
+
return len(DEFAULT_PRIORITY)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SyncPlanItem:
|
|
35
|
+
skill: Skill
|
|
36
|
+
dest: Path
|
|
37
|
+
action: str # "copy" | "update" | "skip"
|
|
38
|
+
note: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SyncReport:
|
|
43
|
+
planned: list[SyncPlanItem] = field(default_factory=list)
|
|
44
|
+
copied: list[SyncPlanItem] = field(default_factory=list)
|
|
45
|
+
updated: list[SyncPlanItem] = field(default_factory=list)
|
|
46
|
+
skipped: list[SyncPlanItem] = field(default_factory=list)
|
|
47
|
+
backed_up: list[Path] = field(default_factory=list)
|
|
48
|
+
errors: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _copy_tree_filtered(src_dir: Path, dest_dir: Path) -> None:
|
|
52
|
+
def ignore(directory: str, names: list[str]) -> set[str]:
|
|
53
|
+
return {n for n in names if n in _EXCLUDE_NAMES}
|
|
54
|
+
|
|
55
|
+
shutil.copytree(src_dir, dest_dir, ignore=ignore, dirs_exist_ok=False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def plan_sync(
|
|
59
|
+
skills: list[Skill],
|
|
60
|
+
target: Path,
|
|
61
|
+
*,
|
|
62
|
+
categories: tuple[str, ...] = ("A",),
|
|
63
|
+
prefix_collisions: set[str] | None = None,
|
|
64
|
+
force: bool = False,
|
|
65
|
+
name_prefix: str = "",
|
|
66
|
+
drifted_only: bool = False,
|
|
67
|
+
sources: tuple[str, ...] | None = None,
|
|
68
|
+
) -> SyncReport:
|
|
69
|
+
report = SyncReport()
|
|
70
|
+
prefix_collisions = prefix_collisions or set()
|
|
71
|
+
planned_dests: dict[Path, str] = {}
|
|
72
|
+
# Sort by source priority first, so the authoritative root (codex) claims a
|
|
73
|
+
# destination before a lower-priority variant (qoder-cn) of the same skill.
|
|
74
|
+
ordered = sorted(skills, key=lambda s: (_priority_rank(s.source), s.name))
|
|
75
|
+
for skill in ordered:
|
|
76
|
+
if skill.category not in categories:
|
|
77
|
+
continue
|
|
78
|
+
if sources and skill.source not in sources:
|
|
79
|
+
continue
|
|
80
|
+
if drifted_only and sync_state(skill) != "drifted":
|
|
81
|
+
continue
|
|
82
|
+
base = skill.name.split(":", 1)[1] if ":" in skill.name else skill.name
|
|
83
|
+
if base in prefix_collisions:
|
|
84
|
+
base = name_prefix + base
|
|
85
|
+
elif name_prefix:
|
|
86
|
+
base = name_prefix + base
|
|
87
|
+
# If the differ already located this skill in the target (including any
|
|
88
|
+
# naming convention like a "superpowers-" prefix), update that exact
|
|
89
|
+
# path instead of computing a fresh one — this prevents creating a
|
|
90
|
+
# duplicate unprefixed directory next to the canonical copy.
|
|
91
|
+
dest = skill.synced_to.parent if skill.synced_to is not None else target / base
|
|
92
|
+
if dest in planned_dests:
|
|
93
|
+
# Another (higher-priority) source variant already claimed this
|
|
94
|
+
# destination in this run — first wins, later variants skip.
|
|
95
|
+
report.skipped.append(
|
|
96
|
+
SyncPlanItem(skill, dest, "skip", f"destination already planned from {planned_dests[dest]}")
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
src_dir = skill.path.parent
|
|
100
|
+
if dest.exists():
|
|
101
|
+
same = False
|
|
102
|
+
try:
|
|
103
|
+
src_skill = skill.path.read_bytes()
|
|
104
|
+
dest_skill = dest / "SKILL.md"
|
|
105
|
+
norm = lambda b: b.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
106
|
+
same = dest_skill.is_file() and norm(dest_skill.read_bytes()) == norm(src_skill)
|
|
107
|
+
except OSError:
|
|
108
|
+
same = False
|
|
109
|
+
if same:
|
|
110
|
+
report.skipped.append(SyncPlanItem(skill, dest, "skip", "identical"))
|
|
111
|
+
elif force:
|
|
112
|
+
report.planned.append(SyncPlanItem(skill, dest, "update", "drifted, --force given"))
|
|
113
|
+
planned_dests[dest] = skill.source
|
|
114
|
+
else:
|
|
115
|
+
report.skipped.append(
|
|
116
|
+
SyncPlanItem(skill, dest, "skip", "exists and differs; use --force to update")
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
if drifted_only:
|
|
120
|
+
report.skipped.append(
|
|
121
|
+
SyncPlanItem(skill, dest, "skip", "not in target (drifted-only mode)")
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
report.planned.append(SyncPlanItem(skill, dest, "copy"))
|
|
125
|
+
planned_dests[dest] = skill.source
|
|
126
|
+
return report
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def execute_sync(
|
|
130
|
+
report: SyncReport,
|
|
131
|
+
target: Path,
|
|
132
|
+
*,
|
|
133
|
+
dry_run: bool = False,
|
|
134
|
+
) -> SyncReport:
|
|
135
|
+
if dry_run:
|
|
136
|
+
return report
|
|
137
|
+
backup_root = target / ".skillsync-backup"
|
|
138
|
+
for item in report.planned:
|
|
139
|
+
src_dir = item.skill.path.parent
|
|
140
|
+
try:
|
|
141
|
+
if item.action == "update" and item.dest.exists():
|
|
142
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
backup_dest = backup_root / item.dest.name
|
|
144
|
+
if backup_dest.exists():
|
|
145
|
+
shutil.rmtree(backup_dest)
|
|
146
|
+
shutil.move(str(item.dest), str(backup_dest))
|
|
147
|
+
report.backed_up.append(backup_dest)
|
|
148
|
+
_copy_tree_filtered(src_dir, item.dest)
|
|
149
|
+
if item.action == "update":
|
|
150
|
+
report.updated.append(item)
|
|
151
|
+
else:
|
|
152
|
+
report.copied.append(item)
|
|
153
|
+
except (OSError, shutil.Error) as exc:
|
|
154
|
+
report.errors.append(f"{item.skill.name}: {exc}")
|
|
155
|
+
return report
|