oasr 0.5.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.
Files changed (59) hide show
  1. __init__.py +3 -0
  2. __main__.py +6 -0
  3. adapter.py +396 -0
  4. adapters/__init__.py +17 -0
  5. adapters/base.py +254 -0
  6. adapters/claude.py +82 -0
  7. adapters/codex.py +84 -0
  8. adapters/copilot.py +210 -0
  9. adapters/cursor.py +78 -0
  10. adapters/windsurf.py +83 -0
  11. agents/__init__.py +25 -0
  12. agents/base.py +96 -0
  13. agents/claude.py +25 -0
  14. agents/codex.py +25 -0
  15. agents/copilot.py +25 -0
  16. agents/opencode.py +25 -0
  17. agents/registry.py +57 -0
  18. cli.py +97 -0
  19. commands/__init__.py +6 -0
  20. commands/adapter.py +102 -0
  21. commands/add.py +435 -0
  22. commands/clean.py +30 -0
  23. commands/clone.py +178 -0
  24. commands/config.py +163 -0
  25. commands/diff.py +180 -0
  26. commands/exec.py +245 -0
  27. commands/find.py +56 -0
  28. commands/help.py +51 -0
  29. commands/info.py +152 -0
  30. commands/list.py +110 -0
  31. commands/registry.py +447 -0
  32. commands/rm.py +128 -0
  33. commands/status.py +119 -0
  34. commands/sync.py +143 -0
  35. commands/update.py +417 -0
  36. commands/use.py +45 -0
  37. commands/validate.py +74 -0
  38. config/__init__.py +119 -0
  39. config/defaults.py +40 -0
  40. config/schema.py +73 -0
  41. discovery.py +145 -0
  42. manifest.py +437 -0
  43. oasr-0.5.0.dist-info/METADATA +358 -0
  44. oasr-0.5.0.dist-info/RECORD +59 -0
  45. oasr-0.5.0.dist-info/WHEEL +4 -0
  46. oasr-0.5.0.dist-info/entry_points.txt +3 -0
  47. oasr-0.5.0.dist-info/licenses/LICENSE +187 -0
  48. oasr-0.5.0.dist-info/licenses/NOTICE +8 -0
  49. policy/__init__.py +50 -0
  50. policy/defaults.py +27 -0
  51. policy/enforcement.py +98 -0
  52. policy/profile.py +185 -0
  53. registry.py +173 -0
  54. remote.py +482 -0
  55. skillcopy/__init__.py +71 -0
  56. skillcopy/local.py +40 -0
  57. skillcopy/remote.py +98 -0
  58. tracking.py +181 -0
  59. validate.py +362 -0
manifest.py ADDED
@@ -0,0 +1,437 @@
1
+ """Skill manifest management for auditing and verification.
2
+
3
+ Manifests track the state of registered skills, enabling:
4
+ - Source verification (content hashing)
5
+ - Change detection (modified files)
6
+ - Existence validation (missing sources)
7
+ - Audit trails (registration timestamps)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import shutil
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Literal
19
+
20
+ from remote import check_remote_reachability, fetch_remote_to_temp
21
+ from skillcopy.remote import is_remote_source
22
+
23
+ MANIFESTS_DIR = "manifests"
24
+ MANIFEST_SUFFIX = ".manifest.json"
25
+ MANIFEST_VERSION = 1
26
+
27
+
28
+ @dataclass
29
+ class FileEntry:
30
+ """A single file in the manifest."""
31
+
32
+ path: str
33
+ hash: str
34
+ size: int
35
+
36
+ def to_dict(self) -> dict:
37
+ return {"path": self.path, "hash": self.hash, "size": self.size}
38
+
39
+ @classmethod
40
+ def from_dict(cls, data: dict) -> FileEntry:
41
+ return cls(path=data["path"], hash=data["hash"], size=data["size"])
42
+
43
+
44
+ @dataclass
45
+ class SkillManifest:
46
+ """Manifest for a registered skill."""
47
+
48
+ name: str
49
+ source_path: str
50
+ description: str
51
+ registered_at: str
52
+ content_hash: str
53
+ files: list[FileEntry] = field(default_factory=list)
54
+ version: int = MANIFEST_VERSION
55
+
56
+ def to_dict(self) -> dict:
57
+ return {
58
+ "version": self.version,
59
+ "name": self.name,
60
+ "source_path": self.source_path,
61
+ "description": self.description,
62
+ "registered_at": self.registered_at,
63
+ "content_hash": self.content_hash,
64
+ "files": [f.to_dict() for f in self.files],
65
+ }
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: dict) -> SkillManifest:
69
+ return cls(
70
+ version=data.get("version", 1),
71
+ name=data["name"],
72
+ source_path=data["source_path"],
73
+ description=data["description"],
74
+ registered_at=data["registered_at"],
75
+ content_hash=data["content_hash"],
76
+ files=[FileEntry.from_dict(f) for f in data.get("files", [])],
77
+ )
78
+
79
+
80
+ SkillStatus = Literal["valid", "modified", "missing", "orphaned", "untracked"]
81
+
82
+
83
+ @dataclass
84
+ class ManifestStatus:
85
+ """Status of a skill manifest check."""
86
+
87
+ name: str
88
+ status: SkillStatus
89
+ source_path: str | None = None
90
+ message: str = ""
91
+ changed_files: list[str] = field(default_factory=list)
92
+ added_files: list[str] = field(default_factory=list)
93
+ removed_files: list[str] = field(default_factory=list)
94
+
95
+ def to_dict(self) -> dict:
96
+ return {
97
+ "name": self.name,
98
+ "status": self.status,
99
+ "source_path": self.source_path,
100
+ "message": self.message,
101
+ "changed_files": self.changed_files,
102
+ "added_files": self.added_files,
103
+ "removed_files": self.removed_files,
104
+ }
105
+
106
+
107
+ def hash_file(path: Path) -> str:
108
+ """Compute SHA256 hash of a file.
109
+
110
+ Args:
111
+ path: Path to the file.
112
+
113
+ Returns:
114
+ Hash string in format "sha256:<hex>".
115
+ """
116
+ hasher = hashlib.sha256()
117
+ try:
118
+ with open(path, "rb") as f:
119
+ for chunk in iter(lambda: f.read(8192), b""):
120
+ hasher.update(chunk)
121
+ return f"sha256:{hasher.hexdigest()}"
122
+ except OSError:
123
+ return "sha256:error"
124
+
125
+
126
+ def hash_directory(path: Path) -> tuple[str, list[FileEntry]]:
127
+ """Compute content hash of a directory.
128
+
129
+ Creates a deterministic hash based on all file paths and their contents.
130
+
131
+ Args:
132
+ path: Path to the directory.
133
+
134
+ Returns:
135
+ Tuple of (content_hash, list of FileEntry).
136
+ """
137
+ entries = []
138
+ hasher = hashlib.sha256()
139
+
140
+ if not path.is_dir():
141
+ return "sha256:missing", []
142
+
143
+ files = sorted(path.rglob("*"))
144
+
145
+ for file_path in files:
146
+ if file_path.is_file():
147
+ rel_path = str(file_path.relative_to(path))
148
+ file_hash = hash_file(file_path)
149
+ file_size = file_path.stat().st_size
150
+
151
+ entries.append(
152
+ FileEntry(
153
+ path=rel_path,
154
+ hash=file_hash,
155
+ size=file_size,
156
+ )
157
+ )
158
+
159
+ hasher.update(rel_path.encode("utf-8"))
160
+ hasher.update(file_hash.encode("utf-8"))
161
+
162
+ return f"sha256:{hasher.hexdigest()}", entries
163
+
164
+
165
+ def get_manifests_dir(config_dir: Path | None = None) -> Path:
166
+ """Get the manifests directory path.
167
+
168
+ Args:
169
+ config_dir: Override config directory (default: ~/.oasr).
170
+
171
+ Returns:
172
+ Path to manifests directory.
173
+ """
174
+ if config_dir is None:
175
+ config_dir = Path.home() / ".oasr"
176
+ return config_dir / MANIFESTS_DIR
177
+
178
+
179
+ def create_manifest(
180
+ name: str,
181
+ source_path: Path | str,
182
+ description: str,
183
+ ) -> SkillManifest:
184
+ """Create a new manifest for a skill.
185
+
186
+ Args:
187
+ name: Skill name.
188
+ source_path: Absolute path to skill directory or remote URL.
189
+ description: Skill description.
190
+
191
+ Returns:
192
+ New SkillManifest instance.
193
+ """
194
+ source_path_str = str(source_path)
195
+
196
+ # Handle remote sources
197
+ if is_remote_source(source_path_str):
198
+ temp_dir = fetch_remote_to_temp(source_path_str)
199
+ try:
200
+ content_hash, files = hash_directory(temp_dir)
201
+ finally:
202
+ shutil.rmtree(temp_dir, ignore_errors=True)
203
+ else:
204
+ # Handle local sources
205
+ content_hash, files = hash_directory(Path(source_path_str))
206
+
207
+ return SkillManifest(
208
+ name=name,
209
+ source_path=source_path_str,
210
+ description=description,
211
+ registered_at=datetime.now(timezone.utc).isoformat(),
212
+ content_hash=content_hash,
213
+ files=files,
214
+ )
215
+
216
+
217
+ def save_manifest(manifest: SkillManifest, config_dir: Path | None = None) -> Path:
218
+ """Save a manifest to disk.
219
+
220
+ Args:
221
+ manifest: Manifest to save.
222
+ config_dir: Override config directory.
223
+
224
+ Returns:
225
+ Path to saved manifest file.
226
+ """
227
+ manifests_dir = get_manifests_dir(config_dir)
228
+ manifests_dir.mkdir(parents=True, exist_ok=True)
229
+
230
+ manifest_path = manifests_dir / f"{manifest.name}{MANIFEST_SUFFIX}"
231
+ manifest_path.write_text(
232
+ json.dumps(manifest.to_dict(), indent=2),
233
+ encoding="utf-8",
234
+ )
235
+
236
+ return manifest_path
237
+
238
+
239
+ def load_manifest(name: str, config_dir: Path | None = None) -> SkillManifest | None:
240
+ """Load a manifest from disk.
241
+
242
+ Args:
243
+ name: Skill name.
244
+ config_dir: Override config directory.
245
+
246
+ Returns:
247
+ SkillManifest if found, None otherwise.
248
+ """
249
+ manifests_dir = get_manifests_dir(config_dir)
250
+ manifest_path = manifests_dir / f"{name}{MANIFEST_SUFFIX}"
251
+
252
+ if not manifest_path.is_file():
253
+ return None
254
+
255
+ try:
256
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
257
+ return SkillManifest.from_dict(data)
258
+ except (json.JSONDecodeError, KeyError, OSError):
259
+ return None
260
+
261
+
262
+ def delete_manifest(name: str, config_dir: Path | None = None) -> bool:
263
+ """Delete a manifest from disk.
264
+
265
+ Args:
266
+ name: Skill name.
267
+ config_dir: Override config directory.
268
+
269
+ Returns:
270
+ True if deleted, False if not found.
271
+ """
272
+ manifests_dir = get_manifests_dir(config_dir)
273
+ manifest_path = manifests_dir / f"{name}{MANIFEST_SUFFIX}"
274
+
275
+ if manifest_path.is_file():
276
+ manifest_path.unlink()
277
+ return True
278
+ return False
279
+
280
+
281
+ def list_manifests(config_dir: Path | None = None) -> list[str]:
282
+ """List all manifest names.
283
+
284
+ Args:
285
+ config_dir: Override config directory.
286
+
287
+ Returns:
288
+ List of skill names with manifests.
289
+ """
290
+ manifests_dir = get_manifests_dir(config_dir)
291
+
292
+ if not manifests_dir.is_dir():
293
+ return []
294
+
295
+ names = []
296
+ for path in manifests_dir.glob(f"*{MANIFEST_SUFFIX}"):
297
+ name = path.name[: -len(MANIFEST_SUFFIX)]
298
+ names.append(name)
299
+
300
+ return sorted(names)
301
+
302
+
303
+ def check_manifest(manifest: SkillManifest) -> ManifestStatus:
304
+ """Check if a manifest matches its source.
305
+
306
+ Args:
307
+ manifest: Manifest to check.
308
+
309
+ Returns:
310
+ ManifestStatus with validation results.
311
+ """
312
+ source_path_str = manifest.source_path
313
+
314
+ # Handle remote sources
315
+ if is_remote_source(source_path_str):
316
+ # Check remote reachability
317
+ reachable, status_code, message = check_remote_reachability(source_path_str)
318
+
319
+ if not reachable:
320
+ if status_code in (404, 410):
321
+ return ManifestStatus(
322
+ name=manifest.name,
323
+ status="missing",
324
+ source_path=source_path_str,
325
+ message=f"Remote source not found: {message}",
326
+ )
327
+ else:
328
+ # Network error - assume valid (transient)
329
+ return ManifestStatus(
330
+ name=manifest.name,
331
+ status="valid",
332
+ source_path=source_path_str,
333
+ message=f"Cannot verify remote (network issue): {message}",
334
+ )
335
+
336
+ # Fetch current content to temp dir
337
+ try:
338
+ temp_dir = fetch_remote_to_temp(source_path_str)
339
+ except Exception as e:
340
+ return ManifestStatus(
341
+ name=manifest.name,
342
+ status="valid",
343
+ source_path=source_path_str,
344
+ message=f"Cannot fetch remote (assuming unchanged): {e}",
345
+ )
346
+
347
+ try:
348
+ current_hash, current_files = hash_directory(temp_dir)
349
+ finally:
350
+ shutil.rmtree(temp_dir, ignore_errors=True)
351
+ else:
352
+ # Handle local sources
353
+ source_path = Path(source_path_str)
354
+
355
+ if not source_path.exists():
356
+ return ManifestStatus(
357
+ name=manifest.name,
358
+ status="missing",
359
+ source_path=source_path_str,
360
+ message=f"Source path no longer exists: {source_path_str}",
361
+ )
362
+
363
+ current_hash, current_files = hash_directory(source_path)
364
+
365
+ # Compare hashes
366
+ if current_hash == manifest.content_hash:
367
+ return ManifestStatus(
368
+ name=manifest.name,
369
+ status="valid",
370
+ source_path=source_path_str,
371
+ message="Source matches manifest",
372
+ )
373
+
374
+ # Compute differences
375
+ current_file_map = {f.path: f for f in current_files}
376
+ manifest_file_map = {f.path: f for f in manifest.files}
377
+
378
+ changed = []
379
+ added = []
380
+ removed = []
381
+
382
+ for path, entry in current_file_map.items():
383
+ if path not in manifest_file_map:
384
+ added.append(path)
385
+ elif entry.hash != manifest_file_map[path].hash:
386
+ changed.append(path)
387
+
388
+ for path in manifest_file_map:
389
+ if path not in current_file_map:
390
+ removed.append(path)
391
+
392
+ return ManifestStatus(
393
+ name=manifest.name,
394
+ status="modified",
395
+ source_path=source_path_str,
396
+ message=f"Source modified: {len(changed)} changed, {len(added)} added, {len(removed)} removed",
397
+ changed_files=changed,
398
+ added_files=added,
399
+ removed_files=removed,
400
+ )
401
+
402
+
403
+ def sync_manifest(manifest: SkillManifest, config_dir: Path | None = None) -> SkillManifest:
404
+ """Update a manifest to match current source state.
405
+
406
+ Args:
407
+ manifest: Existing manifest.
408
+ config_dir: Override config directory.
409
+
410
+ Returns:
411
+ Updated manifest (also saved to disk).
412
+ """
413
+ source_path_str = manifest.source_path
414
+
415
+ # Handle remote sources
416
+ if is_remote_source(source_path_str):
417
+ temp_dir = fetch_remote_to_temp(source_path_str)
418
+ try:
419
+ content_hash, files = hash_directory(temp_dir)
420
+ finally:
421
+ shutil.rmtree(temp_dir, ignore_errors=True)
422
+ else:
423
+ # Handle local sources
424
+ source_path = Path(source_path_str)
425
+ content_hash, files = hash_directory(source_path)
426
+
427
+ updated = SkillManifest(
428
+ name=manifest.name,
429
+ source_path=manifest.source_path,
430
+ description=manifest.description,
431
+ registered_at=manifest.registered_at,
432
+ content_hash=content_hash,
433
+ files=files,
434
+ )
435
+
436
+ save_manifest(updated, config_dir)
437
+ return updated