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.
- __init__.py +3 -0
- __main__.py +6 -0
- adapter.py +396 -0
- adapters/__init__.py +17 -0
- adapters/base.py +254 -0
- adapters/claude.py +82 -0
- adapters/codex.py +84 -0
- adapters/copilot.py +210 -0
- adapters/cursor.py +78 -0
- adapters/windsurf.py +83 -0
- cli.py +94 -0
- commands/__init__.py +6 -0
- commands/adapter.py +102 -0
- commands/add.py +302 -0
- commands/clean.py +155 -0
- commands/diff.py +180 -0
- commands/find.py +56 -0
- commands/help.py +51 -0
- commands/info.py +152 -0
- commands/list.py +110 -0
- commands/registry.py +303 -0
- commands/rm.py +128 -0
- commands/status.py +119 -0
- commands/sync.py +143 -0
- commands/update.py +417 -0
- commands/use.py +172 -0
- commands/validate.py +74 -0
- config.py +86 -0
- discovery.py +145 -0
- manifest.py +437 -0
- oasr-0.3.4.dist-info/METADATA +358 -0
- oasr-0.3.4.dist-info/RECORD +43 -0
- oasr-0.3.4.dist-info/WHEEL +4 -0
- oasr-0.3.4.dist-info/entry_points.txt +3 -0
- oasr-0.3.4.dist-info/licenses/LICENSE +187 -0
- oasr-0.3.4.dist-info/licenses/NOTICE +8 -0
- registry.py +173 -0
- remote.py +482 -0
- skillcopy/__init__.py +71 -0
- skillcopy/local.py +40 -0
- skillcopy/remote.py +98 -0
- tracking.py +181 -0
- validate.py +362 -0
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"
|