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.
Files changed (23) hide show
  1. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/PKG-INFO +5 -1
  2. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/README.md +4 -0
  3. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/PKG-INFO +5 -1
  4. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/SOURCES.txt +1 -0
  5. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/pyproject.toml +1 -1
  6. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/__init__.py +1 -1
  7. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/cli.py +38 -1
  8. agent_skill_sync-0.3.0/skillsync/state.py +102 -0
  9. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/tests/test_skillsync.py +68 -0
  10. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/LICENSE +0 -0
  11. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/dependency_links.txt +0 -0
  12. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/entry_points.txt +0 -0
  13. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/agent_skill_sync.egg-info/top_level.txt +0 -0
  14. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/setup.cfg +0 -0
  15. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/classifier.py +0 -0
  16. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/config.py +0 -0
  17. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/differ.py +0 -0
  18. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/frontmatter.py +0 -0
  19. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/model.py +0 -0
  20. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/report.py +0 -0
  21. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/resolve.py +0 -0
  22. {agent_skill_sync-0.2.0 → agent_skill_sync-0.3.0}/skillsync/scanner.py +0 -0
  23. {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.2.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.2.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
@@ -16,5 +16,6 @@ skillsync/model.py
16
16
  skillsync/report.py
17
17
  skillsync/resolve.py
18
18
  skillsync/scanner.py
19
+ skillsync/state.py
19
20
  skillsync/sync.py
20
21
  tests/test_skillsync.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-skill-sync"
7
- version = "0.2.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" }
@@ -6,6 +6,6 @@ etc. — and want one honest inventory: what exists, where it lives, whether it
6
6
  is runnable, and what it would take to migrate it.
7
7
  """
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.3.0"
10
10
 
11
11
  __all__ = ["__version__"]
@@ -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()