oasr 0.3.4__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.
skillcopy/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """Unified interface for copying skills (local or remote).
2
+
3
+ This module provides a single entry point for copying skills from any source
4
+ (local filesystem or remote URL) to a destination directory.
5
+ """
6
+
7
+ from pathlib import Path
8
+
9
+ from .local import copy_local_skill
10
+ from .remote import copy_remote_skill, is_remote_source
11
+
12
+
13
+ def copy_skill(
14
+ source: str,
15
+ dest: Path,
16
+ *,
17
+ validate: bool = True,
18
+ show_progress: bool = False,
19
+ skill_name: str = None,
20
+ inject_tracking: bool = False,
21
+ source_hash: str | None = None,
22
+ ) -> Path:
23
+ """Copy a skill from source (path or URL) to destination.
24
+
25
+ Args:
26
+ source: Local path or remote URL
27
+ dest: Destination directory
28
+ validate: Whether to validate skill structure after copy
29
+ show_progress: If True, show progress messages for remote skills
30
+ skill_name: Optional skill name for progress messages
31
+ inject_tracking: If True, inject metadata.oasr tracking info
32
+ source_hash: Optional content hash for tracking (required if inject_tracking=True)
33
+
34
+ Returns:
35
+ Path to copied skill directory
36
+
37
+ Raises:
38
+ ValueError: If source is invalid or inject_tracking=True without source_hash
39
+ OSError: If copy operation fails
40
+ """
41
+ if inject_tracking and source_hash is None:
42
+ raise ValueError("source_hash required when inject_tracking=True")
43
+
44
+ if is_remote_source(source):
45
+ dest_path = copy_remote_skill(
46
+ source, dest, validate=validate, show_progress=show_progress, skill_name=skill_name
47
+ )
48
+ else:
49
+ dest_path = copy_local_skill(source, dest, validate=validate)
50
+
51
+ # Inject tracking metadata if requested
52
+ if inject_tracking:
53
+ try:
54
+ from tracking import inject_metadata
55
+
56
+ success = inject_metadata(dest_path, source_hash, source)
57
+ if not success:
58
+ # Log warning but don't fail the copy
59
+ import sys
60
+
61
+ print(f"Warning: Failed to inject tracking metadata for {dest_path.name}", file=sys.stderr)
62
+ except Exception as e:
63
+ # Log warning but don't fail the copy
64
+ import sys
65
+
66
+ print(f"Warning: Error injecting tracking metadata: {e}", file=sys.stderr)
67
+
68
+ return dest_path
69
+
70
+
71
+ __all__ = ["copy_skill", "is_remote_source"]
skillcopy/local.py ADDED
@@ -0,0 +1,40 @@
1
+ """Local filesystem skill copy operations."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+
7
+ def copy_local_skill(source: str, dest: Path, *, validate: bool = True) -> Path:
8
+ """Copy a skill from local filesystem to destination.
9
+
10
+ Args:
11
+ source: Local filesystem path
12
+ dest: Destination directory
13
+ validate: Whether to validate skill structure (reserved for future)
14
+
15
+ Returns:
16
+ Path to copied skill directory
17
+
18
+ Raises:
19
+ FileNotFoundError: If source doesn't exist
20
+ OSError: If copy operation fails
21
+ """
22
+ src_path = Path(source).resolve()
23
+
24
+ if not src_path.exists():
25
+ raise FileNotFoundError(f"Source path does not exist: {source}")
26
+
27
+ if not src_path.is_dir():
28
+ raise ValueError(f"Source is not a directory: {source}")
29
+
30
+ dest = dest.resolve()
31
+
32
+ # Remove existing destination if it exists
33
+ if dest.exists():
34
+ shutil.rmtree(dest)
35
+
36
+ # Copy skill directory
37
+ dest.parent.mkdir(parents=True, exist_ok=True)
38
+ shutil.copytree(src_path, dest)
39
+
40
+ return dest
skillcopy/remote.py ADDED
@@ -0,0 +1,98 @@
1
+ """Remote skill fetch and copy operations."""
2
+
3
+ import shutil
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from remote import fetch_remote_to_temp
8
+
9
+
10
+ def is_remote_source(source: str) -> bool:
11
+ """Check if source is a remote URL.
12
+
13
+ Args:
14
+ source: Path or URL string
15
+
16
+ Returns:
17
+ True if source is a URL, False otherwise
18
+ """
19
+ return isinstance(source, str) and (source.startswith("http://") or source.startswith("https://"))
20
+
21
+
22
+ def copy_remote_skill(
23
+ url: str,
24
+ dest: Path,
25
+ *,
26
+ validate: bool = True,
27
+ force_refresh: bool = False,
28
+ show_progress: bool = False,
29
+ skill_name: str = None,
30
+ ) -> Path:
31
+ """Copy a skill from remote URL to destination.
32
+
33
+ Smart caching: If destination exists and content matches manifest hash,
34
+ skip the fetch to avoid unnecessary API calls.
35
+
36
+ Args:
37
+ url: Remote skill URL
38
+ dest: Destination directory
39
+ validate: Whether to validate skill structure (reserved for future)
40
+ force_refresh: If True, always fetch (ignore cache)
41
+ show_progress: If True, print progress messages
42
+ skill_name: Optional skill name for progress messages
43
+
44
+ Returns:
45
+ Path to copied skill directory
46
+
47
+ Raises:
48
+ ValueError: If URL is invalid
49
+ OSError: If fetch or copy operation fails
50
+ """
51
+ dest = dest.resolve()
52
+
53
+ # Smart check: if destination exists and is up-to-date, skip fetch
54
+ if not force_refresh and dest.exists():
55
+ try:
56
+ from manifest import hash_directory, load_manifest
57
+
58
+ # Try to load manifest to get expected hash
59
+ # Derive skill name from destination directory name
60
+ check_name = skill_name or dest.name
61
+ manifest = load_manifest(check_name)
62
+
63
+ # If manifest source matches this URL, compare hashes
64
+ if manifest and manifest.source_path == url:
65
+ current_hash, _ = hash_directory(dest)
66
+ if current_hash == manifest.content_hash:
67
+ # Destination is up-to-date, no need to fetch
68
+ if show_progress:
69
+ print(f" ✓ {skill_name or dest.name} (cached)", file=sys.stderr)
70
+ return dest
71
+ except Exception:
72
+ # If any error checking cache, proceed with fetch
73
+ pass
74
+
75
+ # Show progress before fetching
76
+ if show_progress:
77
+ platform = "GitHub" if "github.com" in url else "GitLab" if "gitlab.com" in url else "remote"
78
+ print(f" ↓ {skill_name or dest.name} (fetching from {platform}...)", file=sys.stderr, flush=True)
79
+
80
+ # Fetch to temporary directory
81
+ temp_dir = fetch_remote_to_temp(url)
82
+
83
+ try:
84
+ # Remove existing destination if it exists
85
+ if dest.exists():
86
+ shutil.rmtree(dest)
87
+
88
+ # Copy from temp to destination
89
+ dest.parent.mkdir(parents=True, exist_ok=True)
90
+ shutil.copytree(temp_dir, dest)
91
+
92
+ if show_progress:
93
+ print(f" ✓ {skill_name or dest.name} (downloaded)", file=sys.stderr)
94
+
95
+ return dest
96
+ finally:
97
+ # Clean up temporary directory
98
+ shutil.rmtree(temp_dir, ignore_errors=True)
tracking.py ADDED
@@ -0,0 +1,181 @@
1
+ """Skill tracking via metadata.oasr frontmatter injection.
2
+
3
+ This module handles injecting and extracting tracking metadata in SKILL.md files.
4
+ Tracking metadata is stored under the `metadata.oasr` field to comply with the
5
+ Open Agent Skill specification.
6
+ """
7
+
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+
14
+ def inject_metadata(skill_path: Path, content_hash: str, source: str) -> bool:
15
+ """Inject tracking metadata into SKILL.md frontmatter.
16
+
17
+ Args:
18
+ skill_path: Path to skill directory
19
+ content_hash: SHA256 hash of the skill content
20
+ source: Source path or URL of the skill
21
+
22
+ Returns:
23
+ True if metadata was injected, False if SKILL.md not found or injection failed
24
+
25
+ Raises:
26
+ OSError: If file cannot be read or written (permission, encoding issues)
27
+ """
28
+ skill_md = skill_path / "SKILL.md"
29
+ if not skill_md.exists():
30
+ return False
31
+
32
+ try:
33
+ content = skill_md.read_text(encoding="utf-8")
34
+ except (OSError, UnicodeDecodeError) as e:
35
+ raise OSError(f"Failed to read {skill_md}: {e}") from e
36
+
37
+ # Parse existing frontmatter
38
+ frontmatter, body = _split_frontmatter(content)
39
+
40
+ if frontmatter is None:
41
+ # No frontmatter exists - shouldn't happen for valid skills, but handle it
42
+ return False
43
+
44
+ # Validate frontmatter is a dict
45
+ if not isinstance(frontmatter, dict):
46
+ return False
47
+
48
+ # Ensure metadata field exists
49
+ if "metadata" not in frontmatter:
50
+ frontmatter["metadata"] = {}
51
+ elif not isinstance(frontmatter["metadata"], dict):
52
+ # metadata exists but is not a dict - fix it
53
+ frontmatter["metadata"] = {}
54
+
55
+ # Inject oasr tracking metadata
56
+ frontmatter["metadata"]["oasr"] = {
57
+ "hash": content_hash,
58
+ "source": str(source),
59
+ "synced": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
60
+ }
61
+
62
+ # Write back
63
+ try:
64
+ new_content = _serialize_frontmatter(frontmatter) + body
65
+ skill_md.write_text(new_content, encoding="utf-8")
66
+ except (OSError, UnicodeEncodeError) as e:
67
+ raise OSError(f"Failed to write {skill_md}: {e}") from e
68
+
69
+ return True
70
+
71
+
72
+ def extract_metadata(skill_path: Path) -> dict | None:
73
+ """Extract tracking metadata from SKILL.md.
74
+
75
+ Args:
76
+ skill_path: Path to skill directory
77
+
78
+ Returns:
79
+ Dictionary with 'hash', 'source', 'synced' keys, or None if not tracked
80
+ Returns None on any error (file not found, encoding issues, corrupted metadata)
81
+ """
82
+ skill_md = skill_path / "SKILL.md"
83
+ if not skill_md.exists():
84
+ return None
85
+
86
+ try:
87
+ content = skill_md.read_text(encoding="utf-8")
88
+ except (OSError, UnicodeDecodeError):
89
+ # Cannot read file - treat as untracked
90
+ return None
91
+
92
+ frontmatter, _ = _split_frontmatter(content)
93
+
94
+ if frontmatter is None or not isinstance(frontmatter, dict):
95
+ return None
96
+
97
+ # Safely extract metadata.oasr
98
+ metadata = frontmatter.get("metadata")
99
+ if not isinstance(metadata, dict):
100
+ return None
101
+
102
+ oasr = metadata.get("oasr")
103
+ if not isinstance(oasr, dict):
104
+ return None
105
+
106
+ # Validate required fields
107
+ if "hash" not in oasr or "source" not in oasr:
108
+ return None
109
+
110
+ return oasr
111
+
112
+
113
+ def strip_tracking_metadata(frontmatter: dict) -> dict:
114
+ """Remove metadata.oasr from frontmatter dictionary.
115
+
116
+ This is used when comparing registry skills to avoid flagging
117
+ tracking metadata as drift.
118
+
119
+ Args:
120
+ frontmatter: Frontmatter dictionary
121
+
122
+ Returns:
123
+ Copy of frontmatter with metadata.oasr removed
124
+ """
125
+ import copy
126
+
127
+ cleaned = copy.deepcopy(frontmatter)
128
+
129
+ if "metadata" in cleaned and isinstance(cleaned["metadata"], dict):
130
+ cleaned["metadata"].pop("oasr", None)
131
+ # Remove metadata field entirely if it's now empty
132
+ if not cleaned["metadata"]:
133
+ cleaned.pop("metadata")
134
+
135
+ return cleaned
136
+
137
+
138
+ def _split_frontmatter(content: str) -> tuple[dict | None, str]:
139
+ """Split markdown content into frontmatter and body.
140
+
141
+ Args:
142
+ content: Full markdown content
143
+
144
+ Returns:
145
+ Tuple of (frontmatter_dict, body_text)
146
+ """
147
+ if not content.startswith("---"):
148
+ return None, content
149
+
150
+ lines = content.split("\n")
151
+ end_idx = None
152
+
153
+ for i, line in enumerate(lines[1:], start=1):
154
+ if line.strip() == "---":
155
+ end_idx = i
156
+ break
157
+
158
+ if end_idx is None:
159
+ return None, content
160
+
161
+ frontmatter_text = "\n".join(lines[1:end_idx])
162
+ body_text = "\n".join(lines[end_idx + 1 :])
163
+
164
+ try:
165
+ frontmatter = yaml.safe_load(frontmatter_text)
166
+ return frontmatter, body_text
167
+ except yaml.YAMLError:
168
+ return None, content
169
+
170
+
171
+ def _serialize_frontmatter(frontmatter: dict) -> str:
172
+ """Serialize frontmatter dictionary back to YAML with delimiters.
173
+
174
+ Args:
175
+ frontmatter: Frontmatter dictionary
176
+
177
+ Returns:
178
+ YAML string with --- delimiters
179
+ """
180
+ yaml_str = yaml.safe_dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False)
181
+ return f"---\n{yaml_str}---\n"