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
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
+ )