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/config.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Configuration: where to scan, and the target to sync into.
|
|
2
|
+
|
|
3
|
+
Config resolution order (later wins):
|
|
4
|
+
1. Built-in defaults — auto-discover common skill roots on this machine.
|
|
5
|
+
2. ``skillsync.toml`` in the current working directory.
|
|
6
|
+
3. ``skillsync.toml`` under the user's config home.
|
|
7
|
+
4. Explicit ``--config`` path passed on the CLI.
|
|
8
|
+
|
|
9
|
+
The TOML schema is intentionally small::
|
|
10
|
+
|
|
11
|
+
[target]
|
|
12
|
+
label = "workbuddy"
|
|
13
|
+
path = "~/.workbuddy/skills"
|
|
14
|
+
|
|
15
|
+
[[sources]]
|
|
16
|
+
label = "codex"
|
|
17
|
+
path = "~/.codex/skills"
|
|
18
|
+
glob = "*/SKILL.md" # optional, default "*/SKILL.md"
|
|
19
|
+
system = false # optional, mark platform-private roots
|
|
20
|
+
|
|
21
|
+
[deps]
|
|
22
|
+
# name -> how to check availability. "shutil" uses shutil.which,
|
|
23
|
+
# "path" checks a filesystem path, "env" checks an env var is set.
|
|
24
|
+
ffmpeg = { kind = "shutil", value = "ffmpeg" }
|
|
25
|
+
codex = { kind = "shutil", value = "codex" }
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import tomllib
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
DEFAULT_GLOB = "*/SKILL.md"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Dep:
|
|
40
|
+
name: str
|
|
41
|
+
kind: str # "shutil" | "path" | "env"
|
|
42
|
+
value: str
|
|
43
|
+
note: str = ""
|
|
44
|
+
|
|
45
|
+
def available(self) -> bool:
|
|
46
|
+
if self.kind == "shutil":
|
|
47
|
+
import shutil
|
|
48
|
+
|
|
49
|
+
return shutil.which(self.value) is not None
|
|
50
|
+
if self.kind == "path":
|
|
51
|
+
return Path(os.path.expanduser(self.value)).exists()
|
|
52
|
+
if self.kind == "env":
|
|
53
|
+
return bool(os.environ.get(self.value))
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SourceRoot:
|
|
59
|
+
label: str
|
|
60
|
+
path: Path
|
|
61
|
+
glob: str = DEFAULT_GLOB
|
|
62
|
+
system: bool = False
|
|
63
|
+
|
|
64
|
+
def exists(self) -> bool:
|
|
65
|
+
return self.path.is_dir()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class TargetRoot:
|
|
70
|
+
label: str
|
|
71
|
+
path: Path
|
|
72
|
+
glob: str = DEFAULT_GLOB
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Config:
|
|
77
|
+
sources: list[SourceRoot] = field(default_factory=list)
|
|
78
|
+
target: TargetRoot | None = None
|
|
79
|
+
deps: dict[str, Dep] = field(default_factory=dict)
|
|
80
|
+
# Hints that push a skill toward category C/D regardless of deps.
|
|
81
|
+
platform_private_markers: list[str] = field(
|
|
82
|
+
default_factory=lambda: [
|
|
83
|
+
"image_gen", "view_image", "control-in-app-browser", "computer-use",
|
|
84
|
+
"tool_search", "codex-security", "/plugin", "AGENTS.md", "CLAUDE.md",
|
|
85
|
+
"--tools codex", "present-artifact", "publish-artifact-to-sites",
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
config_path: Path | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _expand(p: str) -> Path:
|
|
92
|
+
return Path(os.path.expanduser(os.path.expandvars(p)))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def user_config_home() -> Path:
|
|
96
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
97
|
+
if xdg:
|
|
98
|
+
return Path(xdg)
|
|
99
|
+
appdata = os.environ.get("APPDATA")
|
|
100
|
+
if appdata:
|
|
101
|
+
return Path(appdata) / "skillsync"
|
|
102
|
+
return Path.home() / ".config" / "skillsync"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def default_config() -> Config:
|
|
106
|
+
"""Auto-discover the well-known skill roots on this machine."""
|
|
107
|
+
home = Path.home()
|
|
108
|
+
sources: list[SourceRoot] = []
|
|
109
|
+
|
|
110
|
+
def add(label: str, path: Path, *, system: bool = False, glob: str = DEFAULT_GLOB) -> None:
|
|
111
|
+
if path.is_dir():
|
|
112
|
+
sources.append(SourceRoot(label=label, path=path, glob=glob, system=system))
|
|
113
|
+
|
|
114
|
+
# OpenAI Codex
|
|
115
|
+
add("codex", home / ".codex" / "skills")
|
|
116
|
+
add("codex-system", home / ".codex" / "skills" / ".system", system=True)
|
|
117
|
+
add(
|
|
118
|
+
"codex-marketplace",
|
|
119
|
+
home / ".codex" / "plugins" / "cache",
|
|
120
|
+
glob="**/skills/*/SKILL.md",
|
|
121
|
+
)
|
|
122
|
+
# Claude Code
|
|
123
|
+
add("claude", home / ".claude" / "skills")
|
|
124
|
+
|
|
125
|
+
target = TargetRoot(label="workbuddy", path=home / ".workbuddy" / "skills")
|
|
126
|
+
|
|
127
|
+
return Config(sources=sources, target=target)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_toml(path: Path) -> dict:
|
|
131
|
+
try:
|
|
132
|
+
with path.open("rb") as fh:
|
|
133
|
+
return tomllib.load(fh)
|
|
134
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _merge(cfg: Config, data: dict) -> None:
|
|
139
|
+
if "target" in data and isinstance(data["target"], dict):
|
|
140
|
+
t = data["target"]
|
|
141
|
+
cfg.target = TargetRoot(
|
|
142
|
+
label=t.get("label", cfg.target.label if cfg.target else "target"),
|
|
143
|
+
path=_expand(t["path"]) if "path" in t else (cfg.target.path if cfg.target else Path(".")),
|
|
144
|
+
glob=t.get("glob", DEFAULT_GLOB),
|
|
145
|
+
)
|
|
146
|
+
if "sources" in data and isinstance(data["sources"], list):
|
|
147
|
+
cfg.sources = []
|
|
148
|
+
for s in data["sources"]:
|
|
149
|
+
cfg.sources.append(
|
|
150
|
+
SourceRoot(
|
|
151
|
+
label=s["label"],
|
|
152
|
+
path=_expand(s["path"]),
|
|
153
|
+
glob=s.get("glob", DEFAULT_GLOB),
|
|
154
|
+
system=bool(s.get("system", False)),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
if "deps" in data and isinstance(data["deps"], dict):
|
|
158
|
+
for name, spec in data["deps"].items():
|
|
159
|
+
if isinstance(spec, str):
|
|
160
|
+
cfg.deps[name] = Dep(name=name, kind="shutil", value=spec)
|
|
161
|
+
elif isinstance(spec, dict):
|
|
162
|
+
cfg.deps[name] = Dep(
|
|
163
|
+
name=name,
|
|
164
|
+
kind=spec.get("kind", "shutil"),
|
|
165
|
+
value=spec.get("value", name),
|
|
166
|
+
note=spec.get("note", ""),
|
|
167
|
+
)
|
|
168
|
+
if "platform_private_markers" in data and isinstance(data["platform_private_markers"], list):
|
|
169
|
+
cfg.platform_private_markers = list(data["platform_private_markers"])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load_config(explicit_path: str | None = None) -> Config:
|
|
173
|
+
cfg = default_config()
|
|
174
|
+
for candidate in [
|
|
175
|
+
Path.cwd() / "skillsync.toml",
|
|
176
|
+
user_config_home() / "skillsync.toml",
|
|
177
|
+
]:
|
|
178
|
+
if candidate.is_file():
|
|
179
|
+
_merge(cfg, _load_toml(candidate))
|
|
180
|
+
cfg.config_path = candidate
|
|
181
|
+
if explicit_path:
|
|
182
|
+
p = _expand(explicit_path)
|
|
183
|
+
_merge(cfg, _load_toml(p))
|
|
184
|
+
cfg.config_path = p
|
|
185
|
+
return cfg
|
skillsync/differ.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Differ: compare scanned source skills against the target root.
|
|
2
|
+
|
|
3
|
+
Matching a source skill to its target slot:
|
|
4
|
+
|
|
5
|
+
1. Qualified ``plugin:base`` prefers target dir ``plugin-base`` (the WorkBuddy
|
|
6
|
+
convention for the superpowers family), then falls back to ``base``.
|
|
7
|
+
2. Bare ``base`` prefers target dir ``base``, then tries known prefixed
|
|
8
|
+
variants (``superpowers-base`` …) — qoder-cn stores superpowers skills
|
|
9
|
+
without the prefix, so a bare source name can legitimately own a prefixed
|
|
10
|
+
target slot.
|
|
11
|
+
|
|
12
|
+
States: **missing** (no target slot), **identical** (same content modulo line
|
|
13
|
+
endings), **drifted** (target slot exists but content differs).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .config import Config
|
|
21
|
+
from .model import Skill
|
|
22
|
+
|
|
23
|
+
DEFAULT_PREFIXES = ("superpowers-",)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_target_index(config: Config) -> dict[str, Path]:
|
|
27
|
+
"""Map raw target directory name → SKILL.md path."""
|
|
28
|
+
index: dict[str, Path] = {}
|
|
29
|
+
target = config.target
|
|
30
|
+
if target is None or not target.path.is_dir():
|
|
31
|
+
return index
|
|
32
|
+
for match in sorted(target.path.glob(target.glob)):
|
|
33
|
+
if match.is_file():
|
|
34
|
+
index[match.parent.name] = match
|
|
35
|
+
return index
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def match_target(
|
|
39
|
+
skill_name: str,
|
|
40
|
+
target_index: dict[str, Path],
|
|
41
|
+
prefixes: tuple[str, ...] = DEFAULT_PREFIXES,
|
|
42
|
+
) -> Path | None:
|
|
43
|
+
"""Find the target SKILL.md a source skill name maps onto (or None)."""
|
|
44
|
+
plugin: str | None = None
|
|
45
|
+
base = skill_name
|
|
46
|
+
if ":" in skill_name:
|
|
47
|
+
plugin, base = skill_name.split(":", 1)
|
|
48
|
+
# Qualified names prefer their prefixed slot to avoid hijacking a
|
|
49
|
+
# same-basename skill from a different plugin.
|
|
50
|
+
if plugin:
|
|
51
|
+
prefixed = f"{plugin}-{base}"
|
|
52
|
+
if prefixed in target_index:
|
|
53
|
+
return target_index[prefixed]
|
|
54
|
+
if base in target_index:
|
|
55
|
+
return target_index[base]
|
|
56
|
+
for prefix in prefixes:
|
|
57
|
+
if base.startswith(prefix):
|
|
58
|
+
continue
|
|
59
|
+
variant = prefix + base
|
|
60
|
+
if variant in target_index:
|
|
61
|
+
return target_index[variant]
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _norm(data: bytes) -> bytes:
|
|
66
|
+
return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def diff(skill: Skill, target_index: dict[str, Path]) -> None:
|
|
70
|
+
candidate = match_target(skill.name, target_index)
|
|
71
|
+
if candidate is None:
|
|
72
|
+
skill.synced_to = None
|
|
73
|
+
skill.in_sync = None
|
|
74
|
+
return
|
|
75
|
+
skill.synced_to = candidate
|
|
76
|
+
try:
|
|
77
|
+
same = _norm(candidate.read_bytes()) == _norm(skill.path.read_bytes())
|
|
78
|
+
except OSError:
|
|
79
|
+
same = False
|
|
80
|
+
skill.in_sync = same
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def diff_all(
|
|
84
|
+
skills,
|
|
85
|
+
config: Config,
|
|
86
|
+
prefixes: tuple[str, ...] = DEFAULT_PREFIXES,
|
|
87
|
+
) -> dict[str, Path]:
|
|
88
|
+
target_index = build_target_index(config)
|
|
89
|
+
for skill in skills:
|
|
90
|
+
diff(skill, target_index)
|
|
91
|
+
return target_index
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sync_state(skill: Skill) -> str:
|
|
95
|
+
"""Human-readable sync state for one skill."""
|
|
96
|
+
if skill.synced_to is None:
|
|
97
|
+
return "missing"
|
|
98
|
+
return "identical" if skill.in_sync else "drifted"
|
skillsync/frontmatter.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Minimal YAML frontmatter parser for SKILL.md files.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free (no PyYAML). It supports the subset that skill
|
|
4
|
+
frontmatter actually uses in the wild:
|
|
5
|
+
|
|
6
|
+
* ``key: value`` scalars (quoted or bare)
|
|
7
|
+
* block scalars ``key: >`` / ``key: |`` (folded and literal)
|
|
8
|
+
* inline lists ``key: [a, b]``
|
|
9
|
+
* nested block lists ``key:\\n - item``
|
|
10
|
+
* nested mappings via indentation (one or more levels)
|
|
11
|
+
* ``#`` comments and blank lines
|
|
12
|
+
|
|
13
|
+
This is not a general YAML implementation. It is tuned for the frontmatter
|
|
14
|
+
conventions used by Codex / Claude / WorkBuddy skills and fails soft: anything
|
|
15
|
+
it cannot parse is kept as a raw string rather than raising.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
_FRONTMATTER_RE = re.compile(r"\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*\r?\n?", re.DOTALL)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Document:
|
|
29
|
+
"""A parsed SKILL.md: frontmatter data plus the raw markdown body."""
|
|
30
|
+
|
|
31
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
body: str = ""
|
|
33
|
+
raw_frontmatter: str = ""
|
|
34
|
+
parse_warnings: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def split_frontmatter(text: str) -> tuple[str | None, str]:
|
|
38
|
+
"""Return ``(frontmatter_or_None, body)`` for raw file text."""
|
|
39
|
+
match = _FRONTMATTER_RE.match(text)
|
|
40
|
+
if not match:
|
|
41
|
+
return None, text
|
|
42
|
+
return match.group(1), text[match.end():]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _strip_comment(line: str) -> str:
|
|
46
|
+
"""Remove a trailing ``# comment`` that is not inside quotes."""
|
|
47
|
+
out: list[str] = []
|
|
48
|
+
quote: str | None = None
|
|
49
|
+
for ch in line:
|
|
50
|
+
if quote:
|
|
51
|
+
out.append(ch)
|
|
52
|
+
if ch == quote:
|
|
53
|
+
quote = None
|
|
54
|
+
continue
|
|
55
|
+
if ch in "\"'":
|
|
56
|
+
quote = ch
|
|
57
|
+
out.append(ch)
|
|
58
|
+
continue
|
|
59
|
+
if ch == "#":
|
|
60
|
+
break
|
|
61
|
+
out.append(ch)
|
|
62
|
+
return "".join(out).rstrip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _coerce_scalar(token: str) -> Any:
|
|
66
|
+
token = token.strip()
|
|
67
|
+
if token == "" or token in ("~", "null", "Null", "NULL"):
|
|
68
|
+
return None
|
|
69
|
+
if token in ("true", "True", "TRUE", "yes", "Yes", "on", "On"):
|
|
70
|
+
return True
|
|
71
|
+
if token in ("false", "False", "FALSE", "no", "No", "off", "Off"):
|
|
72
|
+
return False
|
|
73
|
+
if len(token) >= 2 and token[0] == token[-1] and token[0] in "\"'":
|
|
74
|
+
return token[1:-1]
|
|
75
|
+
if token.startswith("[") and token.endswith("]"):
|
|
76
|
+
inner = token[1:-1].strip()
|
|
77
|
+
if not inner:
|
|
78
|
+
return []
|
|
79
|
+
return [_coerce_scalar(part) for part in _split_flow(inner)]
|
|
80
|
+
try:
|
|
81
|
+
return int(token)
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
try:
|
|
85
|
+
return float(token)
|
|
86
|
+
except ValueError:
|
|
87
|
+
pass
|
|
88
|
+
return token
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _split_flow(inner: str) -> list[str]:
|
|
92
|
+
parts: list[str] = []
|
|
93
|
+
depth = 0
|
|
94
|
+
quote: str | None = None
|
|
95
|
+
current: list[str] = []
|
|
96
|
+
for ch in inner:
|
|
97
|
+
if quote:
|
|
98
|
+
current.append(ch)
|
|
99
|
+
if ch == quote:
|
|
100
|
+
quote = None
|
|
101
|
+
continue
|
|
102
|
+
if ch in "\"'":
|
|
103
|
+
quote = ch
|
|
104
|
+
current.append(ch)
|
|
105
|
+
continue
|
|
106
|
+
if ch in "[{":
|
|
107
|
+
depth += 1
|
|
108
|
+
elif ch in "]}":
|
|
109
|
+
depth -= 1
|
|
110
|
+
if ch == "," and depth == 0:
|
|
111
|
+
parts.append("".join(current))
|
|
112
|
+
current = []
|
|
113
|
+
continue
|
|
114
|
+
current.append(ch)
|
|
115
|
+
if current:
|
|
116
|
+
parts.append("".join(current))
|
|
117
|
+
return [p.strip() for p in parts if p.strip()]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_frontmatter(text: str) -> Document:
|
|
121
|
+
"""Parse raw SKILL.md text into a :class:`Document`."""
|
|
122
|
+
raw, body = split_frontmatter(text)
|
|
123
|
+
if raw is None:
|
|
124
|
+
return Document(body=body, parse_warnings=["no frontmatter block found"])
|
|
125
|
+
|
|
126
|
+
doc = Document(body=body, raw_frontmatter=raw)
|
|
127
|
+
# Stack entries: (indent, container). Container is the dict or list we add to.
|
|
128
|
+
root: dict[str, Any] = {}
|
|
129
|
+
stack: list[tuple[int, Any, str | None]] = [(-1, root, None)]
|
|
130
|
+
lines = raw.splitlines()
|
|
131
|
+
i = 0
|
|
132
|
+
while i < len(lines):
|
|
133
|
+
raw_line = lines[i]
|
|
134
|
+
stripped = _strip_comment(raw_line)
|
|
135
|
+
if not stripped.strip():
|
|
136
|
+
i += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
indent = len(raw_line) - len(raw_line.lstrip(" "))
|
|
140
|
+
|
|
141
|
+
# Block scalar: key: > or key: |
|
|
142
|
+
block_match = re.match(r"^(?P<key>[A-Za-z0-9_.\-]+):\s*(?P<style>[>|][-+]?)\s*$", stripped)
|
|
143
|
+
if block_match:
|
|
144
|
+
key = block_match.group("key")
|
|
145
|
+
style = block_match.group("style")[0]
|
|
146
|
+
collected: list[str] = []
|
|
147
|
+
block_indent: int | None = None
|
|
148
|
+
j = i + 1
|
|
149
|
+
while j < len(lines):
|
|
150
|
+
nxt = lines[j]
|
|
151
|
+
if nxt.strip() == "":
|
|
152
|
+
collected.append("")
|
|
153
|
+
j += 1
|
|
154
|
+
continue
|
|
155
|
+
nxt_indent = len(nxt) - len(nxt.lstrip(" "))
|
|
156
|
+
if nxt_indent <= indent:
|
|
157
|
+
break
|
|
158
|
+
if block_indent is None:
|
|
159
|
+
block_indent = nxt_indent
|
|
160
|
+
collected.append(nxt[block_indent:] if len(nxt) >= block_indent else nxt.strip())
|
|
161
|
+
j += 1
|
|
162
|
+
while collected and collected[-1] == "":
|
|
163
|
+
collected.pop()
|
|
164
|
+
value = "\n".join(collected) if style == "|" else " ".join(
|
|
165
|
+
ln.strip() for ln in collected if ln.strip()
|
|
166
|
+
)
|
|
167
|
+
_assign(stack, indent, key, value)
|
|
168
|
+
i = j
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# List item at this indent level
|
|
172
|
+
if stripped.lstrip().startswith("- "):
|
|
173
|
+
item_text = stripped.lstrip()[2:].strip()
|
|
174
|
+
_append_list_item(stack, indent, _coerce_scalar(item_text))
|
|
175
|
+
i += 1
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
kv = re.match(r"^(?P<key>[A-Za-z0-9_.\-]+):\s*(?P<value>.*)$", stripped)
|
|
179
|
+
if kv:
|
|
180
|
+
key = kv.group("key")
|
|
181
|
+
value_text = kv.group("value").strip()
|
|
182
|
+
if value_text == "":
|
|
183
|
+
# Could introduce a nested mapping or a block list — peek ahead.
|
|
184
|
+
container = _open_container(stack, indent, key)
|
|
185
|
+
if container is not None:
|
|
186
|
+
i += 1
|
|
187
|
+
continue
|
|
188
|
+
_assign(stack, indent, key, None)
|
|
189
|
+
i += 1
|
|
190
|
+
continue
|
|
191
|
+
_assign(stack, indent, key, _coerce_scalar(value_text))
|
|
192
|
+
i += 1
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
doc.parse_warnings.append(f"unparsed line {i + 1}: {stripped!r}")
|
|
196
|
+
i += 1
|
|
197
|
+
|
|
198
|
+
doc.data = root
|
|
199
|
+
return doc
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _pop_to(stack: list[tuple[int, Any, str | None]], indent: int) -> list[tuple[int, Any, str | None]]:
|
|
203
|
+
while len(stack) > 1 and stack[-1][0] >= indent:
|
|
204
|
+
stack.pop()
|
|
205
|
+
return stack
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _assign(stack: list[tuple[int, Any, str | None]], indent: int, key: str, value: Any) -> None:
|
|
209
|
+
_pop_to(stack, indent)
|
|
210
|
+
container = stack[-1][1]
|
|
211
|
+
if isinstance(container, dict):
|
|
212
|
+
container[key] = value
|
|
213
|
+
# If the current container is a list, a stray key is ignored.
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _open_container(stack: list[tuple[int, Any, str | None]], indent: int, key: str) -> Any:
|
|
217
|
+
"""Register ``key`` as a container-to-be-filled by following indented lines.
|
|
218
|
+
|
|
219
|
+
Returns the new container, or ``None`` if we cannot decide yet (in which
|
|
220
|
+
case the caller treats the key as an empty scalar; later list items will
|
|
221
|
+
convert it).
|
|
222
|
+
"""
|
|
223
|
+
_pop_to(stack, indent)
|
|
224
|
+
parent = stack[-1][1]
|
|
225
|
+
if not isinstance(parent, dict):
|
|
226
|
+
return None
|
|
227
|
+
placeholder: dict[str, Any] = {}
|
|
228
|
+
parent[key] = placeholder
|
|
229
|
+
stack.append((indent, placeholder, key))
|
|
230
|
+
return placeholder
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _append_list_item(stack: list[tuple[int, Any, str | None]], indent: int, value: Any) -> None:
|
|
234
|
+
_pop_to(stack, indent)
|
|
235
|
+
top_indent, container, key = stack[-1]
|
|
236
|
+
parent_container = stack[-2][1] if len(stack) >= 2 else None
|
|
237
|
+
if isinstance(container, dict) and not container and key and isinstance(parent_container, dict):
|
|
238
|
+
# The placeholder dict we opened is actually a block list.
|
|
239
|
+
new_list: list[Any] = [value]
|
|
240
|
+
parent_container[key] = new_list
|
|
241
|
+
stack[-1] = (top_indent, new_list, key)
|
|
242
|
+
return
|
|
243
|
+
if isinstance(container, list):
|
|
244
|
+
container.append(value)
|
|
245
|
+
return
|
|
246
|
+
if isinstance(container, dict) and key is None:
|
|
247
|
+
return
|
|
248
|
+
# Root-level list items with no owning key are unusual; ignore.
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def parse_file(path) -> Document:
|
|
252
|
+
"""Read ``path`` and parse its frontmatter."""
|
|
253
|
+
import pathlib
|
|
254
|
+
|
|
255
|
+
p = pathlib.Path(path)
|
|
256
|
+
try:
|
|
257
|
+
text = p.read_text(encoding="utf-8")
|
|
258
|
+
except UnicodeDecodeError:
|
|
259
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
260
|
+
return parse_frontmatter(text)
|
skillsync/model.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Core data models: what a "skill" is, and what a scan produces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# Classification taxonomy (mirrors a common migration workflow):
|
|
10
|
+
# A — copied/runnable as-is in the target toolchain
|
|
11
|
+
# B — skill is portable but needs external dependencies (CLI/MCP/venv)
|
|
12
|
+
# C — methodology is reusable but the skill must be rewritten for the target
|
|
13
|
+
# D — bound to the source platform's private runtime; do not migrate
|
|
14
|
+
CATEGORIES = ("A", "B", "C", "D")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Skill:
|
|
19
|
+
"""One discovered SKILL.md and its metadata."""
|
|
20
|
+
|
|
21
|
+
name: str # skill directory name (or qualified name)
|
|
22
|
+
path: Path # absolute path to SKILL.md
|
|
23
|
+
source: str # label of the root it was found under
|
|
24
|
+
root: Path # absolute path of the scan root
|
|
25
|
+
frontmatter: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
description: str = ""
|
|
27
|
+
parse_warnings: list[str] = field(default_factory=list)
|
|
28
|
+
# Derived by the classifier:
|
|
29
|
+
category: str = "?" # A / B / C / D / "?"
|
|
30
|
+
category_reason: str = ""
|
|
31
|
+
missing_deps: list[str] = field(default_factory=list)
|
|
32
|
+
# Derived by the differ:
|
|
33
|
+
synced_to: str | None = None # absolute path in the target, if present
|
|
34
|
+
in_sync: bool | None = None # content identical? (None = not synced)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def qualified_name(self) -> str:
|
|
38
|
+
return f"{self.source}:{self.name}"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def body_hash(self) -> str:
|
|
42
|
+
"""Stable hash of the file content, for drift detection."""
|
|
43
|
+
import hashlib
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
data = self.path.read_bytes()
|
|
47
|
+
except OSError:
|
|
48
|
+
return ""
|
|
49
|
+
return hashlib.sha256(data).hexdigest()[:16]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ScanResult:
|
|
54
|
+
"""Everything one run of the scanner knows."""
|
|
55
|
+
|
|
56
|
+
skills: list[Skill] = field(default_factory=list)
|
|
57
|
+
roots_scanned: list[tuple[str, Path]] = field(default_factory=list)
|
|
58
|
+
errors: list[str] = field(default_factory=list)
|
|
59
|
+
# Populated by resolve(): base_name -> [source labels] for names that were
|
|
60
|
+
# identical across multiple roots and collapsed to a single entry.
|
|
61
|
+
duplicates: list[tuple[str, list[str]]] = field(default_factory=list)
|
|
62
|
+
# (target_name, winner_source, [loser sources]) for stale copies superseded
|
|
63
|
+
# by a higher-priority canonical version.
|
|
64
|
+
shadowed: list[tuple[str, str, list[str]]] = field(default_factory=list)
|
|
65
|
+
resolved: bool = False
|
|
66
|
+
|
|
67
|
+
def by_name(self) -> dict[str, list[Skill]]:
|
|
68
|
+
out: dict[str, list[Skill]] = {}
|
|
69
|
+
for skill in self.skills:
|
|
70
|
+
out.setdefault(skill.name, []).append(skill)
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def by_category(self) -> dict[str, list[Skill]]:
|
|
74
|
+
out: dict[str, list[Skill]] = {c: [] for c in CATEGORIES}
|
|
75
|
+
out["?"] = []
|
|
76
|
+
for skill in self.skills:
|
|
77
|
+
out.setdefault(skill.category, []).append(skill)
|
|
78
|
+
return out
|