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.
- __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
- agents/__init__.py +25 -0
- agents/base.py +96 -0
- agents/claude.py +25 -0
- agents/codex.py +25 -0
- agents/copilot.py +25 -0
- agents/opencode.py +25 -0
- agents/registry.py +57 -0
- cli.py +97 -0
- commands/__init__.py +6 -0
- commands/adapter.py +102 -0
- commands/add.py +435 -0
- commands/clean.py +30 -0
- commands/clone.py +178 -0
- commands/config.py +163 -0
- commands/diff.py +180 -0
- commands/exec.py +245 -0
- commands/find.py +56 -0
- commands/help.py +51 -0
- commands/info.py +152 -0
- commands/list.py +110 -0
- commands/registry.py +447 -0
- commands/rm.py +128 -0
- commands/status.py +119 -0
- commands/sync.py +143 -0
- commands/update.py +417 -0
- commands/use.py +45 -0
- commands/validate.py +74 -0
- config/__init__.py +119 -0
- config/defaults.py +40 -0
- config/schema.py +73 -0
- discovery.py +145 -0
- manifest.py +437 -0
- oasr-0.5.0.dist-info/METADATA +358 -0
- oasr-0.5.0.dist-info/RECORD +59 -0
- oasr-0.5.0.dist-info/WHEEL +4 -0
- oasr-0.5.0.dist-info/entry_points.txt +3 -0
- oasr-0.5.0.dist-info/licenses/LICENSE +187 -0
- oasr-0.5.0.dist-info/licenses/NOTICE +8 -0
- policy/__init__.py +50 -0
- policy/defaults.py +27 -0
- policy/enforcement.py +98 -0
- policy/profile.py +185 -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
config/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Configuration management for ~/.oasr/config.toml."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else:
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
import tomli_w
|
|
13
|
+
|
|
14
|
+
from config.defaults import DEFAULT_CONFIG
|
|
15
|
+
from config.schema import validate_config
|
|
16
|
+
|
|
17
|
+
OASR_DIR = Path.home() / ".oasr"
|
|
18
|
+
CONFIG_FILE = OASR_DIR / "config.toml"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"OASR_DIR",
|
|
22
|
+
"CONFIG_FILE",
|
|
23
|
+
"ensure_oasr_dir",
|
|
24
|
+
"ensure_skills_dir",
|
|
25
|
+
"load_config",
|
|
26
|
+
"save_config",
|
|
27
|
+
"get_default_config",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_oasr_dir() -> Path:
|
|
32
|
+
"""Ensure ~/.oasr/ directory exists."""
|
|
33
|
+
OASR_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
return OASR_DIR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Legacy alias for backwards compatibility
|
|
38
|
+
def ensure_skills_dir() -> Path:
|
|
39
|
+
"""Legacy alias for ensure_oasr_dir()."""
|
|
40
|
+
return ensure_oasr_dir()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
44
|
+
"""Load configuration from TOML file.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config_path: Override config file path. Defaults to ~/.oasr/config.toml.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Configuration dictionary with defaults applied.
|
|
51
|
+
"""
|
|
52
|
+
path = config_path or CONFIG_FILE
|
|
53
|
+
|
|
54
|
+
# Deep copy defaults
|
|
55
|
+
config = {
|
|
56
|
+
"validation": DEFAULT_CONFIG["validation"].copy(),
|
|
57
|
+
"adapter": DEFAULT_CONFIG["adapter"].copy(),
|
|
58
|
+
"agent": DEFAULT_CONFIG["agent"].copy(),
|
|
59
|
+
"oasr": DEFAULT_CONFIG["oasr"].copy(),
|
|
60
|
+
"profiles": {k: v.copy() for k, v in DEFAULT_CONFIG["profiles"].items()},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if path.exists():
|
|
64
|
+
with open(path, "rb") as f:
|
|
65
|
+
loaded = tomllib.load(f)
|
|
66
|
+
|
|
67
|
+
if "validation" in loaded:
|
|
68
|
+
config["validation"].update(loaded["validation"])
|
|
69
|
+
if "adapter" in loaded:
|
|
70
|
+
config["adapter"].update(loaded["adapter"])
|
|
71
|
+
if "agent" in loaded:
|
|
72
|
+
config["agent"].update(loaded["agent"])
|
|
73
|
+
if "oasr" in loaded:
|
|
74
|
+
config["oasr"].update(loaded["oasr"])
|
|
75
|
+
if "profiles" in loaded:
|
|
76
|
+
# Merge user profiles with defaults (user profiles take precedence)
|
|
77
|
+
for profile_name, profile_data in loaded["profiles"].items():
|
|
78
|
+
config["profiles"][profile_name] = profile_data
|
|
79
|
+
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(config: dict[str, Any], config_path: Path | None = None) -> None:
|
|
84
|
+
"""Save configuration to TOML file.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
config: Configuration dictionary to save.
|
|
88
|
+
config_path: Override config file path. Defaults to ~/.oasr/config.toml.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If configuration is invalid.
|
|
92
|
+
"""
|
|
93
|
+
validate_config(config)
|
|
94
|
+
|
|
95
|
+
path = config_path or CONFIG_FILE
|
|
96
|
+
ensure_oasr_dir()
|
|
97
|
+
|
|
98
|
+
# Deep copy and remove None values (TOML can't serialize None)
|
|
99
|
+
config_to_save = {}
|
|
100
|
+
for section, values in config.items():
|
|
101
|
+
if isinstance(values, dict):
|
|
102
|
+
config_to_save[section] = {k: v for k, v in values.items() if v is not None}
|
|
103
|
+
else:
|
|
104
|
+
if values is not None:
|
|
105
|
+
config_to_save[section] = values
|
|
106
|
+
|
|
107
|
+
with open(path, "wb") as f:
|
|
108
|
+
tomli_w.dump(config_to_save, f)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_default_config() -> dict[str, Any]:
|
|
112
|
+
"""Return a copy of the default configuration."""
|
|
113
|
+
return {
|
|
114
|
+
"validation": DEFAULT_CONFIG["validation"].copy(),
|
|
115
|
+
"adapter": DEFAULT_CONFIG["adapter"].copy(),
|
|
116
|
+
"agent": DEFAULT_CONFIG["agent"].copy(),
|
|
117
|
+
"oasr": DEFAULT_CONFIG["oasr"].copy(),
|
|
118
|
+
"profiles": {k: v.copy() for k, v in DEFAULT_CONFIG["profiles"].items()},
|
|
119
|
+
}
|
config/defaults.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Default configuration values."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
6
|
+
"validation": {
|
|
7
|
+
"reference_max_lines": 500,
|
|
8
|
+
"strict": False,
|
|
9
|
+
},
|
|
10
|
+
"adapter": {
|
|
11
|
+
"default_targets": ["cursor", "windsurf"],
|
|
12
|
+
},
|
|
13
|
+
"agent": {
|
|
14
|
+
"default": None,
|
|
15
|
+
},
|
|
16
|
+
"oasr": {
|
|
17
|
+
"default_profile": "safe",
|
|
18
|
+
},
|
|
19
|
+
"profiles": {
|
|
20
|
+
# Built-in safe profile (always available as fallback)
|
|
21
|
+
"safe": {
|
|
22
|
+
"fs_read_roots": ["./"],
|
|
23
|
+
"fs_write_roots": ["./out", "./.oasr"],
|
|
24
|
+
"deny_paths": [
|
|
25
|
+
"~/.ssh",
|
|
26
|
+
"~/.aws",
|
|
27
|
+
"~/.gnupg",
|
|
28
|
+
"~/.config",
|
|
29
|
+
".env",
|
|
30
|
+
"~/.bashrc",
|
|
31
|
+
"~/.zshrc",
|
|
32
|
+
"~/.profile",
|
|
33
|
+
],
|
|
34
|
+
"allowed_commands": ["rg", "fd", "jq", "cat"],
|
|
35
|
+
"deny_shell": True,
|
|
36
|
+
"network": False,
|
|
37
|
+
"allow_env": False,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
config/schema.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Configuration schema validation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
VALID_AGENTS = {"codex", "copilot", "claude", "opencode"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_config(config: dict[str, Any]) -> None:
|
|
9
|
+
"""Validate configuration dictionary.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
config: Configuration dictionary to validate.
|
|
13
|
+
|
|
14
|
+
Raises:
|
|
15
|
+
ValueError: If configuration is invalid.
|
|
16
|
+
"""
|
|
17
|
+
if "agent" in config and "default" in config["agent"]:
|
|
18
|
+
agent = config["agent"]["default"]
|
|
19
|
+
if agent is not None and agent not in VALID_AGENTS:
|
|
20
|
+
raise ValueError(f"Invalid agent '{agent}'. Must be one of: {', '.join(sorted(VALID_AGENTS))}")
|
|
21
|
+
|
|
22
|
+
if "validation" in config:
|
|
23
|
+
if "reference_max_lines" in config["validation"]:
|
|
24
|
+
max_lines = config["validation"]["reference_max_lines"]
|
|
25
|
+
if not isinstance(max_lines, int) or max_lines < 1:
|
|
26
|
+
raise ValueError("validation.reference_max_lines must be a positive integer")
|
|
27
|
+
|
|
28
|
+
if "strict" in config["validation"]:
|
|
29
|
+
if not isinstance(config["validation"]["strict"], bool):
|
|
30
|
+
raise ValueError("validation.strict must be a boolean")
|
|
31
|
+
|
|
32
|
+
if "adapter" in config:
|
|
33
|
+
if "default_targets" in config["adapter"]:
|
|
34
|
+
targets = config["adapter"]["default_targets"]
|
|
35
|
+
if not isinstance(targets, list):
|
|
36
|
+
raise ValueError("adapter.default_targets must be a list")
|
|
37
|
+
|
|
38
|
+
if "oasr" in config:
|
|
39
|
+
if "default_profile" in config["oasr"]:
|
|
40
|
+
profile = config["oasr"]["default_profile"]
|
|
41
|
+
if not isinstance(profile, str):
|
|
42
|
+
raise ValueError("oasr.default_profile must be a string")
|
|
43
|
+
|
|
44
|
+
if "profiles" in config:
|
|
45
|
+
if not isinstance(config["profiles"], dict):
|
|
46
|
+
raise ValueError("profiles must be a table (dictionary)")
|
|
47
|
+
|
|
48
|
+
# Validate each profile structure
|
|
49
|
+
for profile_name, profile_data in config["profiles"].items():
|
|
50
|
+
if not isinstance(profile_data, dict):
|
|
51
|
+
raise ValueError(f"Profile '{profile_name}' must be a table (dictionary)")
|
|
52
|
+
|
|
53
|
+
# Validate profile fields if present
|
|
54
|
+
if "fs_read_roots" in profile_data and not isinstance(profile_data["fs_read_roots"], list):
|
|
55
|
+
raise ValueError(f"Profile '{profile_name}': fs_read_roots must be a list")
|
|
56
|
+
|
|
57
|
+
if "fs_write_roots" in profile_data and not isinstance(profile_data["fs_write_roots"], list):
|
|
58
|
+
raise ValueError(f"Profile '{profile_name}': fs_write_roots must be a list")
|
|
59
|
+
|
|
60
|
+
if "deny_paths" in profile_data and not isinstance(profile_data["deny_paths"], list):
|
|
61
|
+
raise ValueError(f"Profile '{profile_name}': deny_paths must be a list")
|
|
62
|
+
|
|
63
|
+
if "allowed_commands" in profile_data and not isinstance(profile_data["allowed_commands"], list):
|
|
64
|
+
raise ValueError(f"Profile '{profile_name}': allowed_commands must be a list")
|
|
65
|
+
|
|
66
|
+
if "deny_shell" in profile_data and not isinstance(profile_data["deny_shell"], bool):
|
|
67
|
+
raise ValueError(f"Profile '{profile_name}': deny_shell must be a boolean")
|
|
68
|
+
|
|
69
|
+
if "network" in profile_data and not isinstance(profile_data["network"], bool):
|
|
70
|
+
raise ValueError(f"Profile '{profile_name}': network must be a boolean")
|
|
71
|
+
|
|
72
|
+
if "allow_env" in profile_data and not isinstance(profile_data["allow_env"], bool):
|
|
73
|
+
raise ValueError(f"Profile '{profile_name}': allow_env must be a boolean")
|
discovery.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Discovery module for finding SKILL.md files recursively."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class DiscoveredSkill:
|
|
11
|
+
"""A discovered skill from filesystem."""
|
|
12
|
+
|
|
13
|
+
path: Path
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
raw_frontmatter: dict | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_frontmatter(content: str) -> dict | None:
|
|
20
|
+
"""Parse YAML frontmatter from markdown content.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
content: Markdown file content.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Parsed frontmatter dictionary, or None if not found/invalid.
|
|
27
|
+
"""
|
|
28
|
+
if not content.startswith("---"):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
lines = content.split("\n")
|
|
32
|
+
end_idx = None
|
|
33
|
+
|
|
34
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
35
|
+
if line.strip() == "---":
|
|
36
|
+
end_idx = i
|
|
37
|
+
break
|
|
38
|
+
|
|
39
|
+
if end_idx is None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
frontmatter_text = "\n".join(lines[1:end_idx])
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
return yaml.safe_load(frontmatter_text)
|
|
46
|
+
except yaml.YAMLError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def extract_skill_info(skill_md_path: Path) -> tuple[str, str, dict | None]:
|
|
51
|
+
"""Extract name and description from SKILL.md.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
skill_md_path: Path to SKILL.md file.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (name, description, raw_frontmatter).
|
|
58
|
+
Name/description default to empty string if not found.
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
content = skill_md_path.read_text(encoding="utf-8")
|
|
62
|
+
except (OSError, UnicodeDecodeError):
|
|
63
|
+
return "", "", None
|
|
64
|
+
|
|
65
|
+
frontmatter = parse_frontmatter(content)
|
|
66
|
+
|
|
67
|
+
if frontmatter is None:
|
|
68
|
+
return "", "", None
|
|
69
|
+
|
|
70
|
+
name = frontmatter.get("name", "")
|
|
71
|
+
description = frontmatter.get("description", "")
|
|
72
|
+
|
|
73
|
+
if isinstance(name, str):
|
|
74
|
+
name = name.strip()
|
|
75
|
+
else:
|
|
76
|
+
name = ""
|
|
77
|
+
|
|
78
|
+
if isinstance(description, str):
|
|
79
|
+
description = " ".join(description.split())
|
|
80
|
+
else:
|
|
81
|
+
description = ""
|
|
82
|
+
|
|
83
|
+
return name, description, frontmatter
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def find_skills(root: Path) -> list[DiscoveredSkill]:
|
|
87
|
+
"""Find all skills recursively under a root directory.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
root: Root directory to search.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
List of discovered skills.
|
|
94
|
+
"""
|
|
95
|
+
skills = []
|
|
96
|
+
root = root.resolve()
|
|
97
|
+
|
|
98
|
+
if not root.is_dir():
|
|
99
|
+
return skills
|
|
100
|
+
|
|
101
|
+
for skill_md in root.rglob("SKILL.md"):
|
|
102
|
+
skill_dir = skill_md.parent
|
|
103
|
+
name, description, frontmatter = extract_skill_info(skill_md)
|
|
104
|
+
|
|
105
|
+
if not name:
|
|
106
|
+
name = skill_dir.name
|
|
107
|
+
|
|
108
|
+
skills.append(
|
|
109
|
+
DiscoveredSkill(
|
|
110
|
+
path=skill_dir,
|
|
111
|
+
name=name,
|
|
112
|
+
description=description,
|
|
113
|
+
raw_frontmatter=frontmatter,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return skills
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def discover_single(path: Path) -> DiscoveredSkill | None:
|
|
121
|
+
"""Discover a single skill at a given path.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
path: Path to skill directory (containing SKILL.md).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Discovered skill, or None if not a valid skill.
|
|
128
|
+
"""
|
|
129
|
+
path = path.resolve()
|
|
130
|
+
skill_md = path / "SKILL.md"
|
|
131
|
+
|
|
132
|
+
if not skill_md.exists():
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
name, description, frontmatter = extract_skill_info(skill_md)
|
|
136
|
+
|
|
137
|
+
if not name:
|
|
138
|
+
name = path.name
|
|
139
|
+
|
|
140
|
+
return DiscoveredSkill(
|
|
141
|
+
path=path,
|
|
142
|
+
name=name,
|
|
143
|
+
description=description,
|
|
144
|
+
raw_frontmatter=frontmatter,
|
|
145
|
+
)
|