oasr 0.3.4__py3-none-any.whl → 0.4.1__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.
- adapters/base.py +4 -4
- 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 +8 -5
- commands/add.py +137 -4
- commands/clone.py +178 -0
- commands/config.py +163 -0
- commands/exec.py +199 -0
- commands/use.py +17 -144
- config.py → config/__init__.py +40 -17
- config/defaults.py +16 -0
- config/schema.py +36 -0
- {oasr-0.3.4.dist-info → oasr-0.4.1.dist-info}/METADATA +1 -1
- {oasr-0.3.4.dist-info → oasr-0.4.1.dist-info}/RECORD +23 -11
- {oasr-0.3.4.dist-info → oasr-0.4.1.dist-info}/WHEEL +0 -0
- {oasr-0.3.4.dist-info → oasr-0.4.1.dist-info}/entry_points.txt +0 -0
- {oasr-0.3.4.dist-info → oasr-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {oasr-0.3.4.dist-info → oasr-0.4.1.dist-info}/licenses/NOTICE +0 -0
adapters/base.py
CHANGED
|
@@ -105,15 +105,15 @@ class BaseAdapter(ABC):
|
|
|
105
105
|
Path to the copied skill directory.
|
|
106
106
|
"""
|
|
107
107
|
dest = skills_dir / skill.name
|
|
108
|
-
|
|
108
|
+
|
|
109
109
|
# Get the skill's content hash from manifest for tracking
|
|
110
110
|
# If manifest doesn't exist, skip tracking (graceful degradation)
|
|
111
111
|
inject_tracking = False
|
|
112
112
|
source_hash = None
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
try:
|
|
115
115
|
from manifest import load_manifest
|
|
116
|
-
|
|
116
|
+
|
|
117
117
|
manifest = load_manifest(skill.name)
|
|
118
118
|
if manifest:
|
|
119
119
|
source_hash = manifest.content_hash
|
|
@@ -121,7 +121,7 @@ class BaseAdapter(ABC):
|
|
|
121
121
|
except Exception:
|
|
122
122
|
# Gracefully skip tracking if manifest cannot be loaded
|
|
123
123
|
pass
|
|
124
|
-
|
|
124
|
+
|
|
125
125
|
return copy_skill_unified(
|
|
126
126
|
skill.path,
|
|
127
127
|
dest,
|
agents/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Agent driver system."""
|
|
2
|
+
|
|
3
|
+
from agents.base import AgentDriver
|
|
4
|
+
from agents.claude import ClaudeDriver
|
|
5
|
+
from agents.codex import CodexDriver
|
|
6
|
+
from agents.copilot import CopilotDriver
|
|
7
|
+
from agents.opencode import OpenCodeDriver
|
|
8
|
+
from agents.registry import (
|
|
9
|
+
DRIVERS,
|
|
10
|
+
detect_available_agents,
|
|
11
|
+
get_all_agent_names,
|
|
12
|
+
get_driver,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AgentDriver",
|
|
17
|
+
"CodexDriver",
|
|
18
|
+
"CopilotDriver",
|
|
19
|
+
"ClaudeDriver",
|
|
20
|
+
"OpenCodeDriver",
|
|
21
|
+
"DRIVERS",
|
|
22
|
+
"get_driver",
|
|
23
|
+
"detect_available_agents",
|
|
24
|
+
"get_all_agent_names",
|
|
25
|
+
]
|
agents/base.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Abstract base class for agent drivers."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AgentDriver(ABC):
|
|
10
|
+
"""Abstract base class for AI agent CLI drivers."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def get_name(self) -> str:
|
|
14
|
+
"""Get the agent name.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Agent name (e.g., 'codex', 'copilot', 'claude').
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def get_binary_name(self) -> str:
|
|
22
|
+
"""Get the CLI binary name to check for.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Binary name (e.g., 'codex', 'copilot', 'claude').
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def detect(self) -> bool:
|
|
29
|
+
"""Check if the agent binary is available in PATH.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
True if binary is found, False otherwise.
|
|
33
|
+
"""
|
|
34
|
+
return shutil.which(self.get_binary_name()) is not None
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
|
|
38
|
+
"""Build the command to execute.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
skill_content: Full SKILL.md content.
|
|
42
|
+
user_prompt: User's prompt/request.
|
|
43
|
+
cwd: Current working directory.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Command as list of strings (for subprocess).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def execute(self, skill_content: str, user_prompt: str, cwd: Path | None = None) -> subprocess.CompletedProcess:
|
|
50
|
+
"""Execute skill with agent.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
skill_content: Full SKILL.md content.
|
|
54
|
+
user_prompt: User's prompt/request.
|
|
55
|
+
cwd: Working directory for execution (defaults to current dir).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
CompletedProcess with stdout/stderr/returncode.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
FileNotFoundError: If agent binary not found.
|
|
62
|
+
"""
|
|
63
|
+
if not self.detect():
|
|
64
|
+
raise FileNotFoundError(f"{self.get_name()} binary '{self.get_binary_name()}' not found in PATH")
|
|
65
|
+
|
|
66
|
+
working_dir = cwd or Path.cwd()
|
|
67
|
+
cmd = self.build_command(skill_content, user_prompt, working_dir)
|
|
68
|
+
|
|
69
|
+
return subprocess.run(
|
|
70
|
+
cmd,
|
|
71
|
+
cwd=working_dir,
|
|
72
|
+
capture_output=False, # Stream to stdout/stderr
|
|
73
|
+
text=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def format_injected_prompt(self, skill_content: str, user_prompt: str, cwd: Path) -> str:
|
|
77
|
+
"""Format the injected prompt with skill content.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
skill_content: Full SKILL.md content.
|
|
81
|
+
user_prompt: User's prompt/request.
|
|
82
|
+
cwd: Current working directory.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Formatted prompt string.
|
|
86
|
+
"""
|
|
87
|
+
return f"""You are executing a skill. Follow these instructions carefully:
|
|
88
|
+
|
|
89
|
+
━━━━━━━━ SKILL INSTRUCTIONS ━━━━━━━━
|
|
90
|
+
{skill_content}
|
|
91
|
+
━━━━━━━━ END SKILL ━━━━━━━━
|
|
92
|
+
|
|
93
|
+
USER REQUEST: {user_prompt}
|
|
94
|
+
|
|
95
|
+
Working directory: {cwd}
|
|
96
|
+
Execute the skill above for this request."""
|
agents/claude.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Claude CLI agent driver."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from agents.base import AgentDriver
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClaudeDriver(AgentDriver):
|
|
9
|
+
"""Driver for Claude CLI agent."""
|
|
10
|
+
|
|
11
|
+
def get_name(self) -> str:
|
|
12
|
+
"""Get the agent name."""
|
|
13
|
+
return "claude"
|
|
14
|
+
|
|
15
|
+
def get_binary_name(self) -> str:
|
|
16
|
+
"""Get the CLI binary name."""
|
|
17
|
+
return "claude"
|
|
18
|
+
|
|
19
|
+
def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
|
|
20
|
+
"""Build claude command.
|
|
21
|
+
|
|
22
|
+
Claude syntax: claude <prompt> -p
|
|
23
|
+
"""
|
|
24
|
+
injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
|
|
25
|
+
return ["claude", injected_prompt, "-p"]
|
agents/codex.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Codex agent driver."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from agents.base import AgentDriver
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CodexDriver(AgentDriver):
|
|
9
|
+
"""Driver for Codex CLI agent."""
|
|
10
|
+
|
|
11
|
+
def get_name(self) -> str:
|
|
12
|
+
"""Get the agent name."""
|
|
13
|
+
return "codex"
|
|
14
|
+
|
|
15
|
+
def get_binary_name(self) -> str:
|
|
16
|
+
"""Get the CLI binary name."""
|
|
17
|
+
return "codex"
|
|
18
|
+
|
|
19
|
+
def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
|
|
20
|
+
"""Build codex exec command.
|
|
21
|
+
|
|
22
|
+
Codex syntax: codex exec "<prompt>"
|
|
23
|
+
"""
|
|
24
|
+
injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
|
|
25
|
+
return ["codex", "exec", injected_prompt]
|
agents/copilot.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""GitHub Copilot agent driver."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from agents.base import AgentDriver
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CopilotDriver(AgentDriver):
|
|
9
|
+
"""Driver for GitHub Copilot CLI agent."""
|
|
10
|
+
|
|
11
|
+
def get_name(self) -> str:
|
|
12
|
+
"""Get the agent name."""
|
|
13
|
+
return "copilot"
|
|
14
|
+
|
|
15
|
+
def get_binary_name(self) -> str:
|
|
16
|
+
"""Get the CLI binary name."""
|
|
17
|
+
return "copilot"
|
|
18
|
+
|
|
19
|
+
def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
|
|
20
|
+
"""Build copilot command.
|
|
21
|
+
|
|
22
|
+
Copilot syntax: copilot -p "<prompt>"
|
|
23
|
+
"""
|
|
24
|
+
injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
|
|
25
|
+
return ["copilot", "-p", injected_prompt]
|
agents/opencode.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""OpenCode CLI agent driver."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from agents.base import AgentDriver
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OpenCodeDriver(AgentDriver):
|
|
9
|
+
"""Driver for OpenCode CLI agent."""
|
|
10
|
+
|
|
11
|
+
def get_name(self) -> str:
|
|
12
|
+
"""Get the agent name."""
|
|
13
|
+
return "opencode"
|
|
14
|
+
|
|
15
|
+
def get_binary_name(self) -> str:
|
|
16
|
+
"""Get the CLI binary name."""
|
|
17
|
+
return "opencode"
|
|
18
|
+
|
|
19
|
+
def build_command(self, skill_content: str, user_prompt: str, cwd: Path) -> list[str]:
|
|
20
|
+
"""Build opencode run command.
|
|
21
|
+
|
|
22
|
+
OpenCode syntax: opencode run "<prompt>"
|
|
23
|
+
"""
|
|
24
|
+
injected_prompt = self.format_injected_prompt(skill_content, user_prompt, cwd)
|
|
25
|
+
return ["opencode", "run", injected_prompt]
|
agents/registry.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Agent driver registry and factory."""
|
|
2
|
+
|
|
3
|
+
from agents.base import AgentDriver
|
|
4
|
+
from agents.claude import ClaudeDriver
|
|
5
|
+
from agents.codex import CodexDriver
|
|
6
|
+
from agents.copilot import CopilotDriver
|
|
7
|
+
from agents.opencode import OpenCodeDriver
|
|
8
|
+
|
|
9
|
+
# Registry of all available drivers
|
|
10
|
+
DRIVERS: dict[str, type[AgentDriver]] = {
|
|
11
|
+
"codex": CodexDriver,
|
|
12
|
+
"copilot": CopilotDriver,
|
|
13
|
+
"claude": ClaudeDriver,
|
|
14
|
+
"opencode": OpenCodeDriver,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_driver(agent_name: str) -> AgentDriver:
|
|
19
|
+
"""Get driver instance by agent name.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
agent_name: Name of agent (codex, copilot, claude).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
AgentDriver instance.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ValueError: If agent name is invalid.
|
|
29
|
+
"""
|
|
30
|
+
if agent_name not in DRIVERS:
|
|
31
|
+
valid = ", ".join(sorted(DRIVERS.keys()))
|
|
32
|
+
raise ValueError(f"Invalid agent '{agent_name}'. Must be one of: {valid}")
|
|
33
|
+
|
|
34
|
+
return DRIVERS[agent_name]()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_available_agents() -> list[str]:
|
|
38
|
+
"""Detect which agent binaries are available in PATH.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
List of available agent names.
|
|
42
|
+
"""
|
|
43
|
+
available = []
|
|
44
|
+
for name, driver_class in DRIVERS.items():
|
|
45
|
+
driver = driver_class()
|
|
46
|
+
if driver.detect():
|
|
47
|
+
available.append(name)
|
|
48
|
+
return sorted(available)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_all_agent_names() -> list[str]:
|
|
52
|
+
"""Get all supported agent names.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
List of all agent names.
|
|
56
|
+
"""
|
|
57
|
+
return sorted(DRIVERS.keys())
|
cli.py
CHANGED
|
@@ -10,10 +10,10 @@ import json
|
|
|
10
10
|
import sys
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
-
from commands import adapter, clean, diff, find, registry, sync, update, use, validate
|
|
13
|
+
from commands import adapter, clean, clone, config, diff, exec, find, registry, sync, update, use, validate
|
|
14
14
|
from commands import help as help_cmd
|
|
15
15
|
|
|
16
|
-
__version__ = "0.
|
|
16
|
+
__version__ = "0.4.1"
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -67,13 +67,16 @@ def create_parser() -> argparse.ArgumentParser:
|
|
|
67
67
|
|
|
68
68
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
69
69
|
|
|
70
|
-
# New taxonomy (v0.3.0)
|
|
70
|
+
# New taxonomy (v0.3.0+)
|
|
71
71
|
registry.register(subparsers) # Registry operations (add, rm, sync, list)
|
|
72
72
|
diff.register(subparsers) # Show tracked skill status
|
|
73
73
|
sync.register(subparsers) # Refresh tracked skills
|
|
74
|
+
config.register(subparsers) # Configuration management
|
|
75
|
+
clone.register(subparsers) # Clone skills to directory
|
|
76
|
+
exec.register(subparsers) # Execute skills with agent CLI
|
|
74
77
|
|
|
75
|
-
#
|
|
76
|
-
use.register(subparsers)
|
|
78
|
+
# Deprecated commands
|
|
79
|
+
use.register(subparsers) # DEPRECATED - use clone instead
|
|
77
80
|
find.register(subparsers)
|
|
78
81
|
validate.register(subparsers)
|
|
79
82
|
clean.register(subparsers)
|
commands/add.py
CHANGED
|
@@ -139,9 +139,56 @@ def run(args: argparse.Namespace) -> int:
|
|
|
139
139
|
|
|
140
140
|
temp_dir = fetch_remote_to_temp(url)
|
|
141
141
|
|
|
142
|
+
# Check for multiple skills in the repo
|
|
143
|
+
skill_dirs = _find_skill_dirs(temp_dir)
|
|
144
|
+
|
|
145
|
+
if len(skill_dirs) > 1:
|
|
146
|
+
# Multiple skills found
|
|
147
|
+
if not args.quiet and not args.json:
|
|
148
|
+
print(f"✓ Found {len(skill_dirs)} skills in repository:", file=sys.stderr)
|
|
149
|
+
for skill_dir in skill_dirs:
|
|
150
|
+
rel_path = skill_dir.relative_to(temp_dir)
|
|
151
|
+
print(f" - {rel_path}", file=sys.stderr)
|
|
152
|
+
print(file=sys.stderr)
|
|
153
|
+
|
|
154
|
+
# Prompt user
|
|
155
|
+
response = input("Add all skills? [Y/n]: ").strip().lower()
|
|
156
|
+
if response and response not in ("y", "yes"):
|
|
157
|
+
skipped_count += len(skill_dirs)
|
|
158
|
+
for skill_dir in skill_dirs:
|
|
159
|
+
rel_path = skill_dir.relative_to(temp_dir)
|
|
160
|
+
results.append(
|
|
161
|
+
{"url": url, "skill": str(rel_path), "added": False, "reason": "user declined"}
|
|
162
|
+
)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Add all skills
|
|
166
|
+
for skill_dir in skill_dirs:
|
|
167
|
+
rel_path = skill_dir.relative_to(temp_dir)
|
|
168
|
+
skill_url = f"{url}/{rel_path}" if str(rel_path) != "." else url
|
|
169
|
+
|
|
170
|
+
result = _add_single_remote_skill(
|
|
171
|
+
skill_dir,
|
|
172
|
+
skill_url,
|
|
173
|
+
url, # repo_root
|
|
174
|
+
args,
|
|
175
|
+
max_lines,
|
|
176
|
+
results,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if result["added"]:
|
|
180
|
+
added_count += 1
|
|
181
|
+
else:
|
|
182
|
+
skipped_count += 1
|
|
183
|
+
|
|
184
|
+
continue # Skip single-skill handling
|
|
185
|
+
|
|
186
|
+
# Single skill handling (original logic)
|
|
187
|
+
skill_dir = skill_dirs[0] if skill_dirs else temp_dir
|
|
188
|
+
|
|
142
189
|
if not args.quiet and not args.json:
|
|
143
190
|
# Count files validated
|
|
144
|
-
file_count = sum(1 for _ in
|
|
191
|
+
file_count = sum(1 for _ in skill_dir.rglob("*") if _.is_file())
|
|
145
192
|
print(f"✓ Validated {file_count} file(s)", file=sys.stderr)
|
|
146
193
|
except Exception as e:
|
|
147
194
|
skipped_count += 1
|
|
@@ -152,8 +199,8 @@ def run(args: argparse.Namespace) -> int:
|
|
|
152
199
|
continue
|
|
153
200
|
|
|
154
201
|
try:
|
|
155
|
-
# Validate fetched content
|
|
156
|
-
result = validate_skill(
|
|
202
|
+
# Validate fetched content
|
|
203
|
+
result = validate_skill(skill_dir, reference_max_lines=max_lines, skip_name_match=True)
|
|
157
204
|
if not args.quiet and not args.json:
|
|
158
205
|
_print_validation_result(result)
|
|
159
206
|
print()
|
|
@@ -171,7 +218,7 @@ def run(args: argparse.Namespace) -> int:
|
|
|
171
218
|
continue
|
|
172
219
|
|
|
173
220
|
# Discover skill info from fetched content
|
|
174
|
-
discovered = discover_single(
|
|
221
|
+
discovered = discover_single(skill_dir)
|
|
175
222
|
if not discovered:
|
|
176
223
|
skipped_count += 1
|
|
177
224
|
results.append({"url": url, "added": False, "reason": "could not discover skill info"})
|
|
@@ -300,3 +347,89 @@ def _run_recursive(args: argparse.Namespace, root: Path, max_lines: int) -> int:
|
|
|
300
347
|
print(f"\n{added_count} skill(s) added, {skipped_count} skipped")
|
|
301
348
|
|
|
302
349
|
return 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _find_skill_dirs(root: Path) -> list[Path]:
|
|
353
|
+
"""Find all directories containing SKILL.md files.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
root: Root directory to search.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
List of directories containing SKILL.md (sorted by depth, then name).
|
|
360
|
+
"""
|
|
361
|
+
skill_dirs = []
|
|
362
|
+
|
|
363
|
+
for skill_md in root.rglob("SKILL.md"):
|
|
364
|
+
skill_dir = skill_md.parent
|
|
365
|
+
skill_dirs.append(skill_dir)
|
|
366
|
+
|
|
367
|
+
# Sort by depth (shallowest first), then alphabetically
|
|
368
|
+
skill_dirs.sort(key=lambda p: (len(p.relative_to(root).parts), str(p)))
|
|
369
|
+
|
|
370
|
+
return skill_dirs
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _add_single_remote_skill(
|
|
374
|
+
skill_dir: Path,
|
|
375
|
+
skill_url: str,
|
|
376
|
+
repo_root_url: str,
|
|
377
|
+
args: argparse.Namespace,
|
|
378
|
+
max_lines: int,
|
|
379
|
+
results: list[dict],
|
|
380
|
+
) -> dict:
|
|
381
|
+
"""Add a single remote skill.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
skill_dir: Local path to skill directory (in temp).
|
|
385
|
+
skill_url: Full URL to this specific skill.
|
|
386
|
+
repo_root_url: URL to repository root.
|
|
387
|
+
args: Command arguments.
|
|
388
|
+
max_lines: Max reference lines for validation.
|
|
389
|
+
results: Results list to append to.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Result dictionary with 'added' status.
|
|
393
|
+
"""
|
|
394
|
+
# Validate skill
|
|
395
|
+
result = validate_skill(skill_dir, reference_max_lines=max_lines, skip_name_match=True)
|
|
396
|
+
|
|
397
|
+
if not result.valid:
|
|
398
|
+
res = {"url": skill_url, "added": False, "reason": "validation errors"}
|
|
399
|
+
results.append(res)
|
|
400
|
+
if not args.quiet and not args.json:
|
|
401
|
+
print(f"⚠ Skipping {skill_dir.name}: validation errors", file=sys.stderr)
|
|
402
|
+
return res
|
|
403
|
+
|
|
404
|
+
if args.strict and result.warnings:
|
|
405
|
+
res = {"url": skill_url, "added": False, "reason": "validation warnings (strict mode)"}
|
|
406
|
+
results.append(res)
|
|
407
|
+
if not args.quiet and not args.json:
|
|
408
|
+
print(f"⚠ Skipping {skill_dir.name}: validation warnings (strict)", file=sys.stderr)
|
|
409
|
+
return res
|
|
410
|
+
|
|
411
|
+
# Discover skill info
|
|
412
|
+
discovered = discover_single(skill_dir)
|
|
413
|
+
if not discovered:
|
|
414
|
+
res = {"url": skill_url, "added": False, "reason": "could not discover skill info"}
|
|
415
|
+
results.append(res)
|
|
416
|
+
if not args.quiet and not args.json:
|
|
417
|
+
print(f"⚠ Skipping {skill_dir.name}: could not discover skill info", file=sys.stderr)
|
|
418
|
+
return res
|
|
419
|
+
|
|
420
|
+
# Create entry with skill-specific URL
|
|
421
|
+
entry = SkillEntry(
|
|
422
|
+
path=skill_url, # Full path to this skill
|
|
423
|
+
name=discovered.name,
|
|
424
|
+
description=discovered.description,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
is_new = add_skill(entry)
|
|
428
|
+
res = {"name": entry.name, "url": skill_url, "added": True, "new": is_new}
|
|
429
|
+
results.append(res)
|
|
430
|
+
|
|
431
|
+
if not args.quiet and not args.json:
|
|
432
|
+
action = "Added" if is_new else "Updated"
|
|
433
|
+
print(f"{action} skill: {entry.name}", file=sys.stderr)
|
|
434
|
+
|
|
435
|
+
return res
|
commands/clone.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""`oasr clone` command - copy skill(s) to target directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import fnmatch
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from registry import load_registry
|
|
12
|
+
from skillcopy import copy_skill
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(subparsers) -> None:
|
|
16
|
+
"""Register the clone command."""
|
|
17
|
+
p = subparsers.add_parser(
|
|
18
|
+
"clone",
|
|
19
|
+
help="Clone skill(s) to target directory",
|
|
20
|
+
description="Clone skills from the registry to a target directory with tracking metadata",
|
|
21
|
+
)
|
|
22
|
+
p.add_argument("names", nargs="+", help="Skill name(s) or glob pattern(s) to clone")
|
|
23
|
+
p.add_argument(
|
|
24
|
+
"-d",
|
|
25
|
+
"--dir",
|
|
26
|
+
type=Path,
|
|
27
|
+
default=Path("."),
|
|
28
|
+
dest="output_dir",
|
|
29
|
+
help="Target directory (default: current)",
|
|
30
|
+
)
|
|
31
|
+
p.add_argument("--json", action="store_true", help="Output in JSON format")
|
|
32
|
+
p.add_argument("--quiet", action="store_true", help="Suppress info/warnings")
|
|
33
|
+
p.set_defaults(func=run)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _match_skills(patterns: list[str], entry_map: dict) -> tuple[list[str], list[str]]:
|
|
37
|
+
"""Match skill names against patterns (exact or glob).
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple of (matched_names, unmatched_patterns).
|
|
41
|
+
"""
|
|
42
|
+
matched = set()
|
|
43
|
+
unmatched = []
|
|
44
|
+
all_names = list(entry_map.keys())
|
|
45
|
+
|
|
46
|
+
for pattern in patterns:
|
|
47
|
+
if pattern in entry_map:
|
|
48
|
+
matched.add(pattern)
|
|
49
|
+
elif any(c in pattern for c in "*?["):
|
|
50
|
+
# Glob pattern
|
|
51
|
+
matches = fnmatch.filter(all_names, pattern)
|
|
52
|
+
if matches:
|
|
53
|
+
matched.update(matches)
|
|
54
|
+
else:
|
|
55
|
+
unmatched.append(pattern)
|
|
56
|
+
else:
|
|
57
|
+
unmatched.append(pattern)
|
|
58
|
+
|
|
59
|
+
return list(matched), unmatched
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(args: argparse.Namespace) -> int:
|
|
63
|
+
"""Execute the clone command."""
|
|
64
|
+
entries = load_registry()
|
|
65
|
+
entry_map = {e.name: e for e in entries}
|
|
66
|
+
|
|
67
|
+
output_dir = args.output_dir.resolve()
|
|
68
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
copied = []
|
|
71
|
+
warnings = []
|
|
72
|
+
|
|
73
|
+
matched_names, unmatched = _match_skills(args.names, entry_map)
|
|
74
|
+
|
|
75
|
+
for pattern in unmatched:
|
|
76
|
+
warnings.append(f"No skills matched: {pattern}")
|
|
77
|
+
|
|
78
|
+
# Get manifests for tracking metadata
|
|
79
|
+
from manifest import load_manifest
|
|
80
|
+
|
|
81
|
+
# Separate remote and local skills for parallel processing
|
|
82
|
+
from skillcopy.remote import is_remote_source
|
|
83
|
+
|
|
84
|
+
remote_names = [name for name in matched_names if is_remote_source(entry_map[name].path)]
|
|
85
|
+
local_names = [name for name in matched_names if not is_remote_source(entry_map[name].path)]
|
|
86
|
+
|
|
87
|
+
# Handle remote skills with parallel fetching
|
|
88
|
+
if remote_names:
|
|
89
|
+
print(f"Fetching {len(remote_names)} remote skill(s)...", file=sys.stderr)
|
|
90
|
+
import threading
|
|
91
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
92
|
+
|
|
93
|
+
print_lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
def copy_remote_entry(name):
|
|
96
|
+
"""Copy a remote skill with thread-safe progress."""
|
|
97
|
+
entry = entry_map[name]
|
|
98
|
+
dest = output_dir / name
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
with print_lock:
|
|
102
|
+
platform = (
|
|
103
|
+
"GitHub" if "github.com" in entry.path else "GitLab" if "gitlab.com" in entry.path else "remote"
|
|
104
|
+
)
|
|
105
|
+
print(f" ↓ {name} (fetching from {platform}...)", file=sys.stderr, flush=True)
|
|
106
|
+
|
|
107
|
+
# Get manifest hash for tracking
|
|
108
|
+
manifest = load_manifest(name)
|
|
109
|
+
source_hash = manifest.content_hash if manifest else None
|
|
110
|
+
|
|
111
|
+
copy_skill(
|
|
112
|
+
entry.path,
|
|
113
|
+
dest,
|
|
114
|
+
validate=False,
|
|
115
|
+
show_progress=False,
|
|
116
|
+
skill_name=name,
|
|
117
|
+
inject_tracking=True,
|
|
118
|
+
source_hash=source_hash,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
with print_lock:
|
|
122
|
+
print(f" ✓ {name} (downloaded)", file=sys.stderr)
|
|
123
|
+
|
|
124
|
+
return {"name": name, "src": entry.path, "dest": str(dest)}, None
|
|
125
|
+
except Exception as e:
|
|
126
|
+
with print_lock:
|
|
127
|
+
print(f" ✗ {name} ({str(e)[:50]}...)", file=sys.stderr)
|
|
128
|
+
return None, f"Failed to clone {name}: {e}"
|
|
129
|
+
|
|
130
|
+
# Copy remote skills in parallel
|
|
131
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
132
|
+
futures = {executor.submit(copy_remote_entry, name): name for name in remote_names}
|
|
133
|
+
|
|
134
|
+
for future in as_completed(futures):
|
|
135
|
+
result, error = future.result()
|
|
136
|
+
if result:
|
|
137
|
+
copied.append(result)
|
|
138
|
+
if error:
|
|
139
|
+
warnings.append(error)
|
|
140
|
+
|
|
141
|
+
# Handle local skills sequentially (fast anyway)
|
|
142
|
+
for name in sorted(local_names):
|
|
143
|
+
entry = entry_map[name]
|
|
144
|
+
dest = output_dir / name
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
# Get manifest hash for tracking
|
|
148
|
+
manifest = load_manifest(name)
|
|
149
|
+
source_hash = manifest.content_hash if manifest else None
|
|
150
|
+
|
|
151
|
+
# Unified copy with tracking
|
|
152
|
+
copy_skill(entry.path, dest, validate=False, inject_tracking=True, source_hash=source_hash)
|
|
153
|
+
copied.append({"name": name, "src": entry.path, "dest": str(dest)})
|
|
154
|
+
except Exception as e:
|
|
155
|
+
warnings.append(f"Failed to clone {name}: {e}")
|
|
156
|
+
|
|
157
|
+
if not args.quiet:
|
|
158
|
+
for w in warnings:
|
|
159
|
+
print(f"⚠ {w}", file=sys.stderr)
|
|
160
|
+
|
|
161
|
+
if args.json:
|
|
162
|
+
print(
|
|
163
|
+
json.dumps(
|
|
164
|
+
{
|
|
165
|
+
"copied": len(copied),
|
|
166
|
+
"warnings": len(warnings),
|
|
167
|
+
"skills": copied,
|
|
168
|
+
},
|
|
169
|
+
indent=2,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
for c in copied:
|
|
174
|
+
print(f"Cloned: {c['name']} → {c['dest']}")
|
|
175
|
+
if copied:
|
|
176
|
+
print(f"\n{len(copied)} skill(s) cloned to {output_dir}")
|
|
177
|
+
|
|
178
|
+
return 1 if warnings and not copied else 0
|