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/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"
@@ -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