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.
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