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
policy/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Execution policy system for OASR.
|
|
2
|
+
|
|
3
|
+
Enforces host-level security boundaries for skill execution by defining what
|
|
4
|
+
agents can and cannot do. Policy profiles are user-defined in config.toml and
|
|
5
|
+
enforced at runtime before agent invocation.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
import policy
|
|
9
|
+
|
|
10
|
+
# Load profile from config
|
|
11
|
+
profile = policy.load(config, "safe")
|
|
12
|
+
|
|
13
|
+
# Assess risk
|
|
14
|
+
needs_confirm, reasons = policy.assess_risk(
|
|
15
|
+
profile,
|
|
16
|
+
stdin_used=True,
|
|
17
|
+
instructions_file_used=False
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Get confirmation if needed
|
|
21
|
+
if needs_confirm:
|
|
22
|
+
summary = policy.summarize(profile, skill_name, agent_name)
|
|
23
|
+
if not policy.prompt_confirmation(summary, reasons):
|
|
24
|
+
return 1 # aborted
|
|
25
|
+
|
|
26
|
+
This module does NOT:
|
|
27
|
+
- Detect prompt injection via NLP
|
|
28
|
+
- Rewrite or sanitize prompts
|
|
29
|
+
- Validate skill correctness
|
|
30
|
+
- Implement agent-specific permissions
|
|
31
|
+
|
|
32
|
+
It DOES:
|
|
33
|
+
- Load and validate policy profiles from config
|
|
34
|
+
- Assess execution risk based on context
|
|
35
|
+
- Require explicit confirmation for risky operations
|
|
36
|
+
- Fail closed with conservative defaults
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from policy.defaults import SAFE as SAFE_DEFAULTS
|
|
40
|
+
from policy.enforcement import assess_risk, prompt_confirmation
|
|
41
|
+
from policy.profile import Profile, load, summarize
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Profile",
|
|
45
|
+
"load",
|
|
46
|
+
"summarize",
|
|
47
|
+
"assess_risk",
|
|
48
|
+
"prompt_confirmation",
|
|
49
|
+
"SAFE_DEFAULTS",
|
|
50
|
+
]
|
policy/defaults.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Safe default execution policy profile.
|
|
2
|
+
|
|
3
|
+
Conservative defaults that fail closed. Used when:
|
|
4
|
+
- No config exists
|
|
5
|
+
- Requested profile not found
|
|
6
|
+
- Config parsing errors occur
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# Safe default profile - conservative and restrictive
|
|
10
|
+
SAFE = {
|
|
11
|
+
"fs_read_roots": ["./"],
|
|
12
|
+
"fs_write_roots": ["./out", "./.oasr"],
|
|
13
|
+
"deny_paths": [
|
|
14
|
+
"~/.ssh",
|
|
15
|
+
"~/.aws",
|
|
16
|
+
"~/.gnupg",
|
|
17
|
+
"~/.config",
|
|
18
|
+
".env",
|
|
19
|
+
"~/.bashrc",
|
|
20
|
+
"~/.zshrc",
|
|
21
|
+
"~/.profile",
|
|
22
|
+
],
|
|
23
|
+
"allowed_commands": ["rg", "fd", "jq", "cat"],
|
|
24
|
+
"deny_shell": True,
|
|
25
|
+
"network": False,
|
|
26
|
+
"allow_env": False,
|
|
27
|
+
}
|
policy/enforcement.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Policy enforcement logic - risk assessment and confirmation prompts.
|
|
2
|
+
|
|
3
|
+
Determines when execution requires user confirmation and handles the
|
|
4
|
+
confirmation flow.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from policy.profile import Profile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def assess_risk(
|
|
15
|
+
profile: Profile,
|
|
16
|
+
stdin_used: bool,
|
|
17
|
+
instructions_file_used: bool,
|
|
18
|
+
force_confirm: bool = False,
|
|
19
|
+
) -> tuple[bool, list[str]]:
|
|
20
|
+
"""Determine if execution requires user confirmation based on risk triggers.
|
|
21
|
+
|
|
22
|
+
Risk triggers:
|
|
23
|
+
1. Input from stdin (non-interactive) or file
|
|
24
|
+
2. Profile is not "safe"
|
|
25
|
+
3. Environment exposure enabled
|
|
26
|
+
4. Network enabled
|
|
27
|
+
5. Shell execution allowed
|
|
28
|
+
6. Force confirmation flag set
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
profile: Policy profile being used
|
|
32
|
+
stdin_used: True if stdin is non-tty (piped input)
|
|
33
|
+
instructions_file_used: True if prompt read from file
|
|
34
|
+
force_confirm: Force confirmation even if otherwise safe
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Tuple of (requires_confirmation, reasons)
|
|
38
|
+
where reasons is list of human-readable trigger descriptions
|
|
39
|
+
"""
|
|
40
|
+
reasons = []
|
|
41
|
+
|
|
42
|
+
if force_confirm:
|
|
43
|
+
reasons.append("Explicit confirmation requested (--confirm)")
|
|
44
|
+
|
|
45
|
+
if stdin_used:
|
|
46
|
+
reasons.append("Input from stdin (non-interactive)")
|
|
47
|
+
|
|
48
|
+
if instructions_file_used:
|
|
49
|
+
reasons.append("Prompt loaded from file")
|
|
50
|
+
|
|
51
|
+
if profile.name != "safe":
|
|
52
|
+
reasons.append(f"Non-safe profile '{profile.name}' in use")
|
|
53
|
+
|
|
54
|
+
if profile.allow_env:
|
|
55
|
+
reasons.append("Environment variable access enabled")
|
|
56
|
+
|
|
57
|
+
if profile.network:
|
|
58
|
+
reasons.append("Network access enabled")
|
|
59
|
+
|
|
60
|
+
if not profile.deny_shell:
|
|
61
|
+
reasons.append("Shell execution allowed")
|
|
62
|
+
|
|
63
|
+
return (len(reasons) > 0, reasons)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def prompt_confirmation(
|
|
67
|
+
policy_summary: str,
|
|
68
|
+
reasons: list[str],
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""Display policy summary and prompt for user confirmation.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
policy_summary: Formatted policy summary from profile.summarize()
|
|
74
|
+
reasons: List of risk trigger reasons
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
True if user confirms, False otherwise
|
|
78
|
+
|
|
79
|
+
Notes:
|
|
80
|
+
- Writes to stderr (not stdout)
|
|
81
|
+
- Handles Ctrl+C gracefully (returns False)
|
|
82
|
+
- Default answer is No (safe)
|
|
83
|
+
"""
|
|
84
|
+
print(policy_summary, file=sys.stderr)
|
|
85
|
+
print(file=sys.stderr)
|
|
86
|
+
|
|
87
|
+
if reasons:
|
|
88
|
+
print("⚠ This execution requires confirmation due to:", file=sys.stderr)
|
|
89
|
+
for reason in reasons:
|
|
90
|
+
print(f" - {reason}", file=sys.stderr)
|
|
91
|
+
print(file=sys.stderr)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = input("Proceed? [y/N] ").strip().lower()
|
|
95
|
+
return response in ("y", "yes")
|
|
96
|
+
except (EOFError, KeyboardInterrupt):
|
|
97
|
+
print("\nAborted.", file=sys.stderr)
|
|
98
|
+
return False
|
policy/profile.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Policy profile definitions and loading.
|
|
2
|
+
|
|
3
|
+
Defines what agents can do during skill execution. Profiles are user-defined
|
|
4
|
+
in config.toml and enforced at runtime.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from policy.defaults import SAFE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Profile:
|
|
19
|
+
"""Execution policy profile defining agent capabilities and restrictions.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: Profile identifier (e.g., "safe", "dev")
|
|
23
|
+
fs_read_roots: Allowed filesystem read locations
|
|
24
|
+
fs_write_roots: Allowed filesystem write locations
|
|
25
|
+
deny_paths: Explicitly denied paths (takes precedence)
|
|
26
|
+
allowed_commands: Permitted shell commands
|
|
27
|
+
deny_shell: If True, deny all shell/subprocess execution
|
|
28
|
+
network: Allow network access
|
|
29
|
+
allow_env: Allow reading environment variables
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
fs_read_roots: list[str] = field(default_factory=lambda: ["./"])
|
|
34
|
+
fs_write_roots: list[str] = field(default_factory=lambda: ["./out", "./.oasr"])
|
|
35
|
+
deny_paths: list[str] = field(
|
|
36
|
+
default_factory=lambda: [
|
|
37
|
+
"~/.ssh",
|
|
38
|
+
"~/.aws",
|
|
39
|
+
"~/.gnupg",
|
|
40
|
+
"~/.config",
|
|
41
|
+
".env",
|
|
42
|
+
"~/.bashrc",
|
|
43
|
+
"~/.zshrc",
|
|
44
|
+
"~/.profile",
|
|
45
|
+
]
|
|
46
|
+
)
|
|
47
|
+
allowed_commands: list[str] = field(default_factory=lambda: ["rg", "fd", "jq", "cat"])
|
|
48
|
+
deny_shell: bool = True
|
|
49
|
+
network: bool = False
|
|
50
|
+
allow_env: bool = False
|
|
51
|
+
|
|
52
|
+
def __post_init__(self):
|
|
53
|
+
"""Resolve and normalize paths after initialization."""
|
|
54
|
+
self.fs_read_roots = [self._resolve_path(p) for p in self.fs_read_roots]
|
|
55
|
+
self.fs_write_roots = [self._resolve_path(p) for p in self.fs_write_roots]
|
|
56
|
+
self.deny_paths = [self._resolve_path(p) for p in self.deny_paths]
|
|
57
|
+
|
|
58
|
+
def _resolve_path(self, path: str) -> str:
|
|
59
|
+
"""Resolve path: expand ~, make absolute if relative to cwd.
|
|
60
|
+
|
|
61
|
+
Relative paths are workspace-relative (relative to current directory).
|
|
62
|
+
Tilde (~) is expanded to user home directory.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Path string (may contain ~, be relative or absolute)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Normalized absolute path string
|
|
69
|
+
"""
|
|
70
|
+
p = Path(path).expanduser()
|
|
71
|
+
if not p.is_absolute():
|
|
72
|
+
p = Path.cwd() / p
|
|
73
|
+
return str(p.resolve())
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load(config: dict[str, Any], profile_name: str, cwd: Path | None = None) -> Profile:
|
|
77
|
+
"""Load a policy profile from config with fallback to safe defaults.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
config: Full config dict (may contain profiles.<name> tables)
|
|
81
|
+
profile_name: Name of profile to load (e.g., "safe", "dev")
|
|
82
|
+
cwd: Working directory for relative path resolution (default: current)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Profile with merged defaults + user overrides
|
|
86
|
+
|
|
87
|
+
Notes:
|
|
88
|
+
- Missing profiles fall back to hardcoded safe defaults
|
|
89
|
+
- Malformed config falls back to safe + logs warning
|
|
90
|
+
- All paths are resolved to absolute paths
|
|
91
|
+
- Fail-closed behavior: errors → safe defaults
|
|
92
|
+
"""
|
|
93
|
+
if cwd:
|
|
94
|
+
# Temporarily change directory context for path resolution
|
|
95
|
+
import os
|
|
96
|
+
|
|
97
|
+
old_cwd = os.getcwd()
|
|
98
|
+
try:
|
|
99
|
+
os.chdir(cwd)
|
|
100
|
+
return _load_impl(config, profile_name)
|
|
101
|
+
finally:
|
|
102
|
+
os.chdir(old_cwd)
|
|
103
|
+
else:
|
|
104
|
+
return _load_impl(config, profile_name)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_impl(config: dict[str, Any], profile_name: str) -> Profile:
|
|
108
|
+
"""Internal implementation of load()."""
|
|
109
|
+
# Start with safe defaults
|
|
110
|
+
profile_data = SAFE.copy()
|
|
111
|
+
|
|
112
|
+
# Try to load user-defined profile
|
|
113
|
+
try:
|
|
114
|
+
profiles = config.get("profiles", {})
|
|
115
|
+
if profile_name in profiles:
|
|
116
|
+
user_profile = profiles[profile_name]
|
|
117
|
+
# Merge user overrides
|
|
118
|
+
for key in SAFE.keys():
|
|
119
|
+
if key in user_profile:
|
|
120
|
+
profile_data[key] = user_profile[key]
|
|
121
|
+
elif profile_name != "safe":
|
|
122
|
+
# Warn if non-safe profile doesn't exist, but continue with safe defaults
|
|
123
|
+
print(
|
|
124
|
+
f"⚠ Warning: Profile '{profile_name}' not found in config. Using 'safe' defaults.",
|
|
125
|
+
file=sys.stderr,
|
|
126
|
+
)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
# Fail closed: any error in config parsing → safe defaults
|
|
129
|
+
print(
|
|
130
|
+
f"⚠ Warning: Error loading profile config: {e}. Using 'safe' defaults.",
|
|
131
|
+
file=sys.stderr,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return Profile(name=profile_name, **profile_data)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def summarize(profile: Profile, skill_name: str, agent_name: str) -> str:
|
|
138
|
+
"""Generate human-readable policy summary for confirmation prompt.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
profile: Policy profile to summarize
|
|
142
|
+
skill_name: Name of skill being executed
|
|
143
|
+
agent_name: Name of agent being used
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Formatted multi-line string suitable for stderr output
|
|
147
|
+
"""
|
|
148
|
+
lines = [
|
|
149
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
150
|
+
"EXECUTION POLICY REVIEW",
|
|
151
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
152
|
+
f"Skill: {skill_name}",
|
|
153
|
+
f"Agent: {agent_name}",
|
|
154
|
+
f"Profile: {profile.name}",
|
|
155
|
+
f"Network: {'allowed' if profile.network else 'denied'}",
|
|
156
|
+
f"Environment: {'allowed' if profile.allow_env else 'denied'}",
|
|
157
|
+
f"Shell: {'allowed' if not profile.deny_shell else 'denied'}",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
# Format allowed commands (truncate if too long)
|
|
161
|
+
if profile.allowed_commands:
|
|
162
|
+
commands = ", ".join(profile.allowed_commands)
|
|
163
|
+
if len(commands) > 60:
|
|
164
|
+
commands = commands[:57] + "..."
|
|
165
|
+
lines.append(f"Allowed commands: {commands}")
|
|
166
|
+
else:
|
|
167
|
+
lines.append("Allowed commands: (none)")
|
|
168
|
+
|
|
169
|
+
# Format paths (show first few, indicate if more)
|
|
170
|
+
lines.append(f"Read roots: {', '.join(profile.fs_read_roots[:3])}")
|
|
171
|
+
if len(profile.fs_read_roots) > 3:
|
|
172
|
+
lines.append(f" ... and {len(profile.fs_read_roots) - 3} more")
|
|
173
|
+
|
|
174
|
+
lines.append(f"Write roots: {', '.join(profile.fs_write_roots[:3])}")
|
|
175
|
+
if len(profile.fs_write_roots) > 3:
|
|
176
|
+
lines.append(f" ... and {len(profile.fs_write_roots) - 3} more")
|
|
177
|
+
|
|
178
|
+
# Show some deny paths (not all - could be long)
|
|
179
|
+
if profile.deny_paths:
|
|
180
|
+
deny_sample = profile.deny_paths[:5]
|
|
181
|
+
lines.append(f"Deny paths: {', '.join(deny_sample)}")
|
|
182
|
+
if len(profile.deny_paths) > 5:
|
|
183
|
+
lines.append(f" ... and {len(profile.deny_paths) - 5} more")
|
|
184
|
+
|
|
185
|
+
return "\n".join(lines)
|
registry.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Registry management for ~/.oasr/registry.toml."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
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 import OASR_DIR, ensure_skills_dir
|
|
15
|
+
from manifest import create_manifest, delete_manifest, save_manifest
|
|
16
|
+
|
|
17
|
+
REGISTRY_FILE = OASR_DIR / "registry.toml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SkillEntry:
|
|
22
|
+
"""A registered skill entry."""
|
|
23
|
+
|
|
24
|
+
path: str
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, str]:
|
|
29
|
+
"""Convert to dictionary for TOML serialization."""
|
|
30
|
+
return {
|
|
31
|
+
"path": self.path,
|
|
32
|
+
"name": self.name,
|
|
33
|
+
"description": self.description,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, str]) -> "SkillEntry":
|
|
38
|
+
"""Create from dictionary."""
|
|
39
|
+
return cls(
|
|
40
|
+
path=data.get("path", ""),
|
|
41
|
+
name=data.get("name", ""),
|
|
42
|
+
description=data.get("description", ""),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_registry(registry_path: Path | None = None) -> list[SkillEntry]:
|
|
47
|
+
"""Load registry from TOML file.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
registry_path: Override registry file path. Defaults to ~/.oasr/registry.toml.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
List of registered skill entries.
|
|
54
|
+
"""
|
|
55
|
+
path = registry_path or REGISTRY_FILE
|
|
56
|
+
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
with open(path, "rb") as f:
|
|
61
|
+
data = tomllib.load(f)
|
|
62
|
+
|
|
63
|
+
skills = data.get("skill", [])
|
|
64
|
+
return [SkillEntry.from_dict(s) for s in skills]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_registry(entries: list[SkillEntry], registry_path: Path | None = None) -> None:
|
|
68
|
+
"""Save registry to TOML file.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
entries: List of skill entries to save.
|
|
72
|
+
registry_path: Override registry file path. Defaults to ~/.skills/registry.toml.
|
|
73
|
+
"""
|
|
74
|
+
path = registry_path or REGISTRY_FILE
|
|
75
|
+
ensure_skills_dir()
|
|
76
|
+
|
|
77
|
+
data = {"skill": [e.to_dict() for e in entries]}
|
|
78
|
+
|
|
79
|
+
with open(path, "wb") as f:
|
|
80
|
+
tomli_w.dump(data, f)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def add_skill(
|
|
84
|
+
entry: SkillEntry,
|
|
85
|
+
registry_path: Path | None = None,
|
|
86
|
+
create_manifest_artifact: bool = True,
|
|
87
|
+
) -> bool:
|
|
88
|
+
"""Add or update a skill in the registry.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
entry: Skill entry to add.
|
|
92
|
+
registry_path: Override registry file path.
|
|
93
|
+
create_manifest_artifact: Whether to create/update the manifest artifact.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if skill was added, False if it was updated (already existed).
|
|
97
|
+
"""
|
|
98
|
+
entries = load_registry(registry_path)
|
|
99
|
+
is_new = True
|
|
100
|
+
|
|
101
|
+
for i, existing in enumerate(entries):
|
|
102
|
+
if existing.name == entry.name or existing.path == entry.path:
|
|
103
|
+
entries[i] = entry
|
|
104
|
+
is_new = False
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
if is_new:
|
|
108
|
+
entries.append(entry)
|
|
109
|
+
|
|
110
|
+
save_registry(entries, registry_path)
|
|
111
|
+
|
|
112
|
+
if create_manifest_artifact:
|
|
113
|
+
manifest = create_manifest(
|
|
114
|
+
name=entry.name,
|
|
115
|
+
source_path=entry.path, # Keep as string (can be URL or path)
|
|
116
|
+
description=entry.description,
|
|
117
|
+
)
|
|
118
|
+
save_manifest(manifest)
|
|
119
|
+
|
|
120
|
+
return is_new
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove_skill(
|
|
124
|
+
name_or_path: str,
|
|
125
|
+
registry_path: Path | None = None,
|
|
126
|
+
delete_manifest_artifact: bool = True,
|
|
127
|
+
) -> bool:
|
|
128
|
+
"""Remove a skill from the registry by name or path.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
name_or_path: Skill name or path to remove.
|
|
132
|
+
registry_path: Override registry file path.
|
|
133
|
+
delete_manifest_artifact: Whether to delete the manifest artifact.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if skill was removed, False if not found.
|
|
137
|
+
"""
|
|
138
|
+
entries = load_registry(registry_path)
|
|
139
|
+
removed_name = None
|
|
140
|
+
|
|
141
|
+
new_entries = []
|
|
142
|
+
for e in entries:
|
|
143
|
+
if e.name == name_or_path or e.path == name_or_path:
|
|
144
|
+
removed_name = e.name
|
|
145
|
+
else:
|
|
146
|
+
new_entries.append(e)
|
|
147
|
+
|
|
148
|
+
if removed_name:
|
|
149
|
+
save_registry(new_entries, registry_path)
|
|
150
|
+
if delete_manifest_artifact:
|
|
151
|
+
delete_manifest(removed_name)
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_skill(name_or_path: str, registry_path: Path | None = None) -> SkillEntry | None:
|
|
158
|
+
"""Find a skill by name or path.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
name_or_path: Skill name or path to find.
|
|
162
|
+
registry_path: Override registry file path.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Skill entry if found, None otherwise.
|
|
166
|
+
"""
|
|
167
|
+
entries = load_registry(registry_path)
|
|
168
|
+
|
|
169
|
+
for entry in entries:
|
|
170
|
+
if entry.name == name_or_path or entry.path == name_or_path:
|
|
171
|
+
return entry
|
|
172
|
+
|
|
173
|
+
return None
|