agent-skill-sync 0.2.0__tar.gz → 0.3.0__tar.gz
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.2.0 → agent_skill_sync-0.3.0}/PKG-INFO +5 -1
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/README.md +4 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/PKG-INFO +5 -1
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/SOURCES.txt +1 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/pyproject.toml +1 -1
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/__init__.py +1 -1
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/cli.py +38 -1
- agent_skill_sync-0.3.0/skillsync/state.py +102 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/tests/test_skillsync.py +68 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/LICENSE +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/dependency_links.txt +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/entry_points.txt +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/top_level.txt +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/setup.cfg +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/classifier.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/config.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/differ.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/frontmatter.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/model.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/report.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/resolve.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/scanner.py +0 -0
- {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/sync.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-skill-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Scan, classify and sync AI-agent skills (SKILL.md) across toolchains like OpenAI Codex, Claude Code and WorkBuddy.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/kina-cmd/agent-skill-sync
|
|
@@ -100,6 +100,10 @@ skillsync scan --json # machine-readable inventory
|
|
|
100
100
|
# or paste into a "tool reuse" skill for your agent to read
|
|
101
101
|
skillsync index --out ./output --lang zh
|
|
102
102
|
|
|
103
|
+
# What changed upstream since I last looked?
|
|
104
|
+
skillsync check # diffs against the last snapshot, then records a new one
|
|
105
|
+
skillsync check --quiet # summary line only (cron-friendly)
|
|
106
|
+
|
|
103
107
|
# Sync portable skills into the target root — plan first, always
|
|
104
108
|
skillsync sync --dry-run
|
|
105
109
|
skillsync sync --categories A
|
|
@@ -77,6 +77,10 @@ skillsync scan --json # machine-readable inventory
|
|
|
77
77
|
# or paste into a "tool reuse" skill for your agent to read
|
|
78
78
|
skillsync index --out ./output --lang zh
|
|
79
79
|
|
|
80
|
+
# What changed upstream since I last looked?
|
|
81
|
+
skillsync check # diffs against the last snapshot, then records a new one
|
|
82
|
+
skillsync check --quiet # summary line only (cron-friendly)
|
|
83
|
+
|
|
80
84
|
# Sync portable skills into the target root — plan first, always
|
|
81
85
|
skillsync sync --dry-run
|
|
82
86
|
skillsync sync --categories A
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-skill-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Scan, classify and sync AI-agent skills (SKILL.md) across toolchains like OpenAI Codex, Claude Code and WorkBuddy.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/kina-cmd/agent-skill-sync
|
|
@@ -100,6 +100,10 @@ skillsync scan --json # machine-readable inventory
|
|
|
100
100
|
# or paste into a "tool reuse" skill for your agent to read
|
|
101
101
|
skillsync index --out ./output --lang zh
|
|
102
102
|
|
|
103
|
+
# What changed upstream since I last looked?
|
|
104
|
+
skillsync check # diffs against the last snapshot, then records a new one
|
|
105
|
+
skillsync check --quiet # summary line only (cron-friendly)
|
|
106
|
+
|
|
103
107
|
# Sync portable skills into the target root — plan first, always
|
|
104
108
|
skillsync sync --dry-run
|
|
105
109
|
skillsync sync --categories A
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "agent-skill-sync"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Scan, classify and sync AI-agent skills (SKILL.md) across toolchains like OpenAI Codex, Claude Code and WorkBuddy."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -9,15 +9,22 @@ from pathlib import Path
|
|
|
9
9
|
|
|
10
10
|
from . import __version__
|
|
11
11
|
from .classifier import classify_all
|
|
12
|
-
from .config import load_config
|
|
12
|
+
from .config import load_config, user_config_home
|
|
13
13
|
from .differ import diff_all, sync_state
|
|
14
14
|
from .model import CATEGORIES
|
|
15
15
|
from .report import to_markdown, write_outputs
|
|
16
16
|
from .resolve import resolve
|
|
17
17
|
from .scanner import scan
|
|
18
|
+
from .state import compare, format_report, load_snapshot, save_snapshot, snapshot_from
|
|
18
19
|
from .sync import execute_sync, plan_sync
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
def _state_path(args) -> Path:
|
|
23
|
+
if getattr(args, "state", None):
|
|
24
|
+
return Path(args.state).expanduser()
|
|
25
|
+
return user_config_home() / "state.json"
|
|
26
|
+
|
|
27
|
+
|
|
21
28
|
def _prepare(cfg, *, dedupe: bool = False):
|
|
22
29
|
"""Scan → classify → diff → (optionally) resolve cross-source duplicates."""
|
|
23
30
|
result = scan(cfg)
|
|
@@ -56,6 +63,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
56
63
|
p_index = sub.add_parser("index", help="generate INDEX.md + inventory.json")
|
|
57
64
|
p_index.add_argument("--out", default=".", help="output directory (default: cwd)")
|
|
58
65
|
p_index.add_argument("--lang", choices=["zh", "en"], default="zh")
|
|
66
|
+
p_index.add_argument("--state", help="path to the change-detection snapshot (default: config home)")
|
|
67
|
+
|
|
68
|
+
p_check = sub.add_parser(
|
|
69
|
+
"check",
|
|
70
|
+
help="report what changed upstream since the last run (and record a new baseline)",
|
|
71
|
+
)
|
|
72
|
+
p_check.add_argument("--state", help="path to the change-detection snapshot (default: config home)")
|
|
73
|
+
p_check.add_argument("--quiet", action="store_true", help="print only the summary line")
|
|
59
74
|
|
|
60
75
|
p_sync = sub.add_parser("sync", help="copy portable skills into the target root")
|
|
61
76
|
p_sync.add_argument("--categories", default="A", help="comma list, e.g. A,B (default: A)")
|
|
@@ -152,12 +167,33 @@ def _do_index(cfg, args) -> int:
|
|
|
152
167
|
paths = write_outputs(result, out_dir, lang=args.lang)
|
|
153
168
|
for p in paths:
|
|
154
169
|
print(f"wrote {p}")
|
|
170
|
+
# Change detection: report what moved upstream, then refresh the baseline.
|
|
171
|
+
state_path = _state_path(args)
|
|
172
|
+
old = load_snapshot(state_path)
|
|
173
|
+
changes = compare(old, result)
|
|
174
|
+
print()
|
|
175
|
+
print(format_report(changes, old))
|
|
176
|
+
save_snapshot(state_path, snapshot_from(result))
|
|
155
177
|
# Also echo the markdown to stdout for quick piping.
|
|
156
178
|
print()
|
|
157
179
|
print(to_markdown(result, lang=args.lang)[:2000])
|
|
158
180
|
return 0
|
|
159
181
|
|
|
160
182
|
|
|
183
|
+
def _do_check(cfg, args) -> int:
|
|
184
|
+
result = _prepare(cfg, dedupe=getattr(args, "dedupe", False))
|
|
185
|
+
state_path = _state_path(args)
|
|
186
|
+
old = load_snapshot(state_path)
|
|
187
|
+
changes = compare(old, result)
|
|
188
|
+
report = format_report(changes, old)
|
|
189
|
+
if args.quiet:
|
|
190
|
+
print(report.splitlines()[0])
|
|
191
|
+
else:
|
|
192
|
+
print(report)
|
|
193
|
+
save_snapshot(state_path, snapshot_from(result))
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
161
197
|
def _do_sync(cfg, args) -> int:
|
|
162
198
|
if cfg.target is None or not cfg.target.path.is_dir():
|
|
163
199
|
print("[error] target root missing; set [target] in skillsync.toml", file=sys.stderr)
|
|
@@ -210,6 +246,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
210
246
|
"status": _do_status,
|
|
211
247
|
"index": _do_index,
|
|
212
248
|
"sync": _do_sync,
|
|
249
|
+
"check": _do_check,
|
|
213
250
|
}
|
|
214
251
|
return handlers[args.command](cfg, args)
|
|
215
252
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Snapshot state: detect upstream changes between runs.
|
|
2
|
+
|
|
3
|
+
Each ``check`` / ``index`` run records a snapshot of every scanned skill
|
|
4
|
+
(content hash + category + sync state). The next run compares against it and
|
|
5
|
+
reports what moved upstream: new skills, removed skills, content changes,
|
|
6
|
+
category re-classifications and new drift against the target.
|
|
7
|
+
|
|
8
|
+
This is the "come back and maintain me" hook: the tool tells you, in one
|
|
9
|
+
screen, exactly what changed since you last looked.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import datetime as _dt
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .differ import sync_state
|
|
20
|
+
from .model import ScanResult, Skill
|
|
21
|
+
|
|
22
|
+
KINDS = ("new", "removed", "content", "category", "sync")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Change:
|
|
27
|
+
kind: str # one of KINDS
|
|
28
|
+
key: str # "<source>:<name>"
|
|
29
|
+
detail: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _key(skill: Skill) -> str:
|
|
33
|
+
return f"{skill.source}:{skill.name}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def snapshot_from(result: ScanResult) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"generated_at": _dt.datetime.now().isoformat(timespec="seconds"),
|
|
39
|
+
"skills": {
|
|
40
|
+
_key(s): {
|
|
41
|
+
"hash": s.body_hash,
|
|
42
|
+
"category": s.category,
|
|
43
|
+
"sync": sync_state(s),
|
|
44
|
+
}
|
|
45
|
+
for s in result.skills
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_snapshot(path: Path) -> dict | None:
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
53
|
+
except (OSError, json.JSONDecodeError):
|
|
54
|
+
return None
|
|
55
|
+
if not isinstance(data, dict) or not isinstance(data.get("skills"), dict):
|
|
56
|
+
return None
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_snapshot(path: Path, snap: dict) -> None:
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
path.write_text(json.dumps(snap, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compare(old: dict | None, result: ScanResult) -> list[Change]:
|
|
66
|
+
"""Diff the previous snapshot against the current scan.
|
|
67
|
+
|
|
68
|
+
An absent baseline (first run) yields no changes — there is nothing to
|
|
69
|
+
compare against yet.
|
|
70
|
+
"""
|
|
71
|
+
changes: list[Change] = []
|
|
72
|
+
current = snapshot_from(result)["skills"]
|
|
73
|
+
if old is None:
|
|
74
|
+
return changes
|
|
75
|
+
prev = old.get("skills", {})
|
|
76
|
+
for key, cur in sorted(current.items()):
|
|
77
|
+
if key not in prev:
|
|
78
|
+
changes.append(Change("new", key, f"category {cur['category']}"))
|
|
79
|
+
continue
|
|
80
|
+
p = prev[key]
|
|
81
|
+
if p.get("hash") != cur["hash"]:
|
|
82
|
+
changes.append(Change("content", key, "upstream content changed"))
|
|
83
|
+
if p.get("category") != cur["category"]:
|
|
84
|
+
changes.append(
|
|
85
|
+
Change("category", key, f"{p.get('category')} -> {cur['category']}")
|
|
86
|
+
)
|
|
87
|
+
if p.get("sync") != cur["sync"]:
|
|
88
|
+
changes.append(Change("sync", key, f"{p.get('sync')} -> {cur['sync']}"))
|
|
89
|
+
for key in sorted(set(prev) - set(current)):
|
|
90
|
+
changes.append(Change("removed", key, "no longer present in any source root"))
|
|
91
|
+
return changes
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_report(changes: list[Change], old: dict | None) -> str:
|
|
95
|
+
if old is None:
|
|
96
|
+
return "first run: baseline recorded; future runs report upstream changes"
|
|
97
|
+
if not changes:
|
|
98
|
+
return "no upstream changes since last run"
|
|
99
|
+
lines = [f"{len(changes)} change(s) since {old.get('generated_at', 'last run')}:"]
|
|
100
|
+
for c in changes:
|
|
101
|
+
lines.append(f" [{c.kind:8}] {c.key} {c.detail}")
|
|
102
|
+
return "\n".join(lines)
|
|
@@ -340,5 +340,73 @@ class TestSyncControls(unittest.TestCase):
|
|
|
340
340
|
self.assertIn("already planned", report.skipped[0].note)
|
|
341
341
|
|
|
342
342
|
|
|
343
|
+
class TestStateChangeDetection(unittest.TestCase):
|
|
344
|
+
def setUp(self):
|
|
345
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
346
|
+
base = Path(self.tmp.name)
|
|
347
|
+
self.src = base / "src"
|
|
348
|
+
self.tgt = base / "tgt"
|
|
349
|
+
self.src.mkdir()
|
|
350
|
+
self.tgt.mkdir()
|
|
351
|
+
self.cfg = Config(
|
|
352
|
+
sources=[SourceRoot(label="src", path=self.src)],
|
|
353
|
+
target=TargetRoot(label="tgt", path=self.tgt),
|
|
354
|
+
)
|
|
355
|
+
self.state_path = base / "state.json"
|
|
356
|
+
|
|
357
|
+
def tearDown(self):
|
|
358
|
+
self.tmp.cleanup()
|
|
359
|
+
|
|
360
|
+
def _scan(self):
|
|
361
|
+
from skillsync.classifier import classify_all
|
|
362
|
+
from skillsync.differ import diff_all
|
|
363
|
+
from skillsync.scanner import scan
|
|
364
|
+
|
|
365
|
+
r = scan(self.cfg)
|
|
366
|
+
classify_all(r.skills, self.cfg)
|
|
367
|
+
diff_all(r.skills, self.cfg)
|
|
368
|
+
return r
|
|
369
|
+
|
|
370
|
+
def test_first_run_is_baseline(self):
|
|
371
|
+
from skillsync.state import compare, load_snapshot, save_snapshot, snapshot_from
|
|
372
|
+
|
|
373
|
+
_write_skill(self.src, "a", "# a\n")
|
|
374
|
+
r = self._scan()
|
|
375
|
+
self.assertEqual(compare(None, r), [])
|
|
376
|
+
save_snapshot(self.state_path, snapshot_from(r))
|
|
377
|
+
self.assertIsNotNone(load_snapshot(self.state_path))
|
|
378
|
+
|
|
379
|
+
def test_detects_content_new_and_removed(self):
|
|
380
|
+
from skillsync.state import compare, load_snapshot, save_snapshot, snapshot_from
|
|
381
|
+
|
|
382
|
+
_write_skill(self.src, "a", "# a v1\n")
|
|
383
|
+
_write_skill(self.src, "gone", "# gone\n")
|
|
384
|
+
r1 = self._scan()
|
|
385
|
+
save_snapshot(self.state_path, snapshot_from(r1))
|
|
386
|
+
|
|
387
|
+
# upstream: modify a, remove gone, add b
|
|
388
|
+
(self.src / "a" / "SKILL.md").write_text("---\nname: a\n---\n# a v2\n", encoding="utf-8")
|
|
389
|
+
import shutil
|
|
390
|
+
|
|
391
|
+
shutil.rmtree(self.src / "gone")
|
|
392
|
+
_write_skill(self.src, "b", "# b\n")
|
|
393
|
+
r2 = self._scan()
|
|
394
|
+
|
|
395
|
+
changes = compare(load_snapshot(self.state_path), r2)
|
|
396
|
+
kinds = {(c.kind, c.key.split(":", 1)[1]) for c in changes}
|
|
397
|
+
self.assertIn(("content", "a"), kinds)
|
|
398
|
+
self.assertIn(("removed", "gone"), kinds)
|
|
399
|
+
self.assertIn(("new", "b"), kinds)
|
|
400
|
+
|
|
401
|
+
def test_corrupt_snapshot_treated_as_absent(self):
|
|
402
|
+
from skillsync.state import compare, load_snapshot
|
|
403
|
+
|
|
404
|
+
self.state_path.write_text("{ not json", encoding="utf-8")
|
|
405
|
+
_write_skill(self.src, "a", "# a\n")
|
|
406
|
+
r = self._scan()
|
|
407
|
+
self.assertIsNone(load_snapshot(self.state_path))
|
|
408
|
+
self.assertEqual(compare(None, r), [])
|
|
409
|
+
|
|
410
|
+
|
|
343
411
|
if __name__ == "__main__":
|
|
344
412
|
unittest.main()
|
|
File without changes
|
{agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|