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
adapters/claude.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Claude adapter for generating .claude/commands/*.md files.
|
|
2
|
+
|
|
3
|
+
Claude Code uses markdown command files similar to Cursor's format,
|
|
4
|
+
stored in .claude/commands/ directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from adapters.base import BaseAdapter, SkillInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ClaudeAdapter(BaseAdapter):
|
|
13
|
+
"""Adapter for generating Claude Code command files."""
|
|
14
|
+
|
|
15
|
+
target_name = "claude"
|
|
16
|
+
target_subdir = ".claude/commands"
|
|
17
|
+
|
|
18
|
+
def generate(
|
|
19
|
+
self, skill: SkillInfo, output_dir: Path, copy: bool = False, base_output_dir: Path | None = None
|
|
20
|
+
) -> Path:
|
|
21
|
+
"""Generate a Claude command file for a skill.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
skill: Skill information.
|
|
25
|
+
output_dir: Resolved output directory (.claude/commands/).
|
|
26
|
+
copy: If True, use relative paths to local skill copies.
|
|
27
|
+
base_output_dir: Base output directory (for computing relative paths).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Path to the generated file.
|
|
31
|
+
"""
|
|
32
|
+
output_file = output_dir / f"{skill.name}.md"
|
|
33
|
+
|
|
34
|
+
skill_path = self.get_skill_path(skill, base_output_dir or output_dir.parent.parent, copy)
|
|
35
|
+
|
|
36
|
+
content = f"""# {skill.name}
|
|
37
|
+
|
|
38
|
+
{skill.description}
|
|
39
|
+
|
|
40
|
+
This command delegates to the agent skill at `{skill_path}/`.
|
|
41
|
+
|
|
42
|
+
## Skill Location
|
|
43
|
+
|
|
44
|
+
- **Path:** `{skill_path}/`
|
|
45
|
+
- **Manifest:** `{skill_path}/SKILL.md`
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
output_file.write_text(content, encoding="utf-8")
|
|
49
|
+
return output_file
|
|
50
|
+
|
|
51
|
+
def cleanup_stale(self, output_dir: Path, valid_names: set[str]) -> list[Path]:
|
|
52
|
+
"""Remove stale Claude command files.
|
|
53
|
+
|
|
54
|
+
Only removes files that look like generated skill commands.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
output_dir: Output directory to clean.
|
|
58
|
+
valid_names: Set of valid skill names (files to keep).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
List of removed file paths.
|
|
62
|
+
"""
|
|
63
|
+
removed = []
|
|
64
|
+
|
|
65
|
+
if not output_dir.is_dir():
|
|
66
|
+
return removed
|
|
67
|
+
|
|
68
|
+
for file in output_dir.glob("*.md"):
|
|
69
|
+
name = file.stem
|
|
70
|
+
|
|
71
|
+
if name in valid_names:
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
content = file.read_text(encoding="utf-8")
|
|
76
|
+
if "This command delegates to the agent skill at" in content:
|
|
77
|
+
file.unlink()
|
|
78
|
+
removed.append(file)
|
|
79
|
+
except (OSError, UnicodeDecodeError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
return removed
|
adapters/codex.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Codex adapter for generating codex skill files.
|
|
2
|
+
|
|
3
|
+
Note: Codex format is TBD. Currently uses same format as Cursor.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from adapters.base import BaseAdapter, SkillInfo
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CodexAdapter(BaseAdapter):
|
|
12
|
+
"""Adapter for generating Codex skill files.
|
|
13
|
+
|
|
14
|
+
Placeholder implementation - uses same format as Cursor commands.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
target_name = "codex"
|
|
18
|
+
target_subdir = ".codex/skills"
|
|
19
|
+
|
|
20
|
+
def generate(
|
|
21
|
+
self, skill: SkillInfo, output_dir: Path, copy: bool = False, base_output_dir: Path | None = None
|
|
22
|
+
) -> Path:
|
|
23
|
+
"""Generate a Codex skill file for a skill.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
skill: Skill information.
|
|
27
|
+
output_dir: Resolved output directory (.codex/skills/).
|
|
28
|
+
copy: If True, use relative paths to local skill copies.
|
|
29
|
+
base_output_dir: Base output directory (for computing relative paths).
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Path to the generated file.
|
|
33
|
+
"""
|
|
34
|
+
output_file = output_dir / f"{skill.name}.md"
|
|
35
|
+
|
|
36
|
+
skill_path = self.get_skill_path(skill, base_output_dir or output_dir.parent.parent, copy)
|
|
37
|
+
|
|
38
|
+
content = f"""# {skill.name}
|
|
39
|
+
|
|
40
|
+
{skill.description}
|
|
41
|
+
|
|
42
|
+
This skill delegates to the agent skill at `{skill_path}/`.
|
|
43
|
+
|
|
44
|
+
## Skill Location
|
|
45
|
+
|
|
46
|
+
- **Path:** `{skill_path}/`
|
|
47
|
+
- **Manifest:** `{skill_path}/SKILL.md`
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
output_file.write_text(content, encoding="utf-8")
|
|
51
|
+
return output_file
|
|
52
|
+
|
|
53
|
+
def cleanup_stale(self, output_dir: Path, valid_names: set[str]) -> list[Path]:
|
|
54
|
+
"""Remove stale Codex skill files.
|
|
55
|
+
|
|
56
|
+
Only removes files that look like generated skill files.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
output_dir: Output directory to clean.
|
|
60
|
+
valid_names: Set of valid skill names (files to keep).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of removed file paths.
|
|
64
|
+
"""
|
|
65
|
+
removed = []
|
|
66
|
+
|
|
67
|
+
if not output_dir.is_dir():
|
|
68
|
+
return removed
|
|
69
|
+
|
|
70
|
+
for file in output_dir.glob("*.md"):
|
|
71
|
+
name = file.stem
|
|
72
|
+
|
|
73
|
+
if name in valid_names:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
content = file.read_text(encoding="utf-8")
|
|
78
|
+
if "This skill delegates to the agent skill at" in content:
|
|
79
|
+
file.unlink()
|
|
80
|
+
removed.append(file)
|
|
81
|
+
except (OSError, UnicodeDecodeError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
return removed
|
adapters/copilot.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Copilot adapter for generating GitHub Copilot integration files.
|
|
2
|
+
|
|
3
|
+
GitHub Copilot supports three types of custom content:
|
|
4
|
+
1. Prompt files (.github/prompts/*.prompt.md) - Invokable via /name in chat
|
|
5
|
+
2. Instructions file (.github/copilot-instructions.md) - Auto-injected repo-wide rules
|
|
6
|
+
3. Scoped instructions (.github/instructions/*.instructions.md) - Path-specific rules
|
|
7
|
+
|
|
8
|
+
This adapter generates:
|
|
9
|
+
- Per-skill prompt files for invokable workflows (/skill-name)
|
|
10
|
+
- A consolidated instructions file listing available skills
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from adapters.base import BaseAdapter, SkillInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CopilotAdapter(BaseAdapter):
|
|
19
|
+
"""Adapter for generating GitHub Copilot files.
|
|
20
|
+
|
|
21
|
+
Generates:
|
|
22
|
+
- .github/prompts/{skill}.prompt.md - Invokable via /{skill} in chat
|
|
23
|
+
- .github/copilot-instructions.md - Repository-wide skill index (auto-injected)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
target_name = "copilot"
|
|
27
|
+
target_subdir = ".github/prompts"
|
|
28
|
+
|
|
29
|
+
MARKER = "<!-- ASR-MANAGED-SKILLS -->"
|
|
30
|
+
MARKER_END = "<!-- /ASR-MANAGED-SKILLS -->"
|
|
31
|
+
|
|
32
|
+
def generate(
|
|
33
|
+
self, skill: SkillInfo, output_dir: Path, copy: bool = False, base_output_dir: Path | None = None
|
|
34
|
+
) -> Path:
|
|
35
|
+
"""Generate a prompt file for a single skill.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
skill: Skill information.
|
|
39
|
+
output_dir: Resolved output directory (.github/prompts/).
|
|
40
|
+
copy: If True, use relative paths to local skill copies.
|
|
41
|
+
base_output_dir: Base output directory (for computing relative paths).
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Path to the generated prompt file.
|
|
45
|
+
"""
|
|
46
|
+
output_file = output_dir / f"{skill.name}.prompt.md"
|
|
47
|
+
|
|
48
|
+
skill_path = self.get_skill_path(skill, base_output_dir or output_dir.parent.parent, copy)
|
|
49
|
+
|
|
50
|
+
content = f"""# {skill.name}
|
|
51
|
+
|
|
52
|
+
{skill.description}
|
|
53
|
+
|
|
54
|
+
This prompt delegates to the agent skill at `{skill_path}/`.
|
|
55
|
+
|
|
56
|
+
## Skill Location
|
|
57
|
+
|
|
58
|
+
- **Path:** `{skill_path}/`
|
|
59
|
+
- **Manifest:** `{skill_path}/SKILL.md`
|
|
60
|
+
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
Invoke this skill by typing `/{skill.name}` in the Copilot chat.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
output_file.write_text(content, encoding="utf-8")
|
|
67
|
+
return output_file
|
|
68
|
+
|
|
69
|
+
def cleanup_stale(self, output_dir: Path, valid_names: set[str]) -> list[Path]:
|
|
70
|
+
"""Remove stale Copilot prompt files.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
output_dir: Output directory to clean (.github/prompts/).
|
|
74
|
+
valid_names: Set of valid skill names (files to keep).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of removed file paths.
|
|
78
|
+
"""
|
|
79
|
+
removed = []
|
|
80
|
+
|
|
81
|
+
if not output_dir.is_dir():
|
|
82
|
+
return removed
|
|
83
|
+
|
|
84
|
+
for file in output_dir.glob("*.prompt.md"):
|
|
85
|
+
name = file.stem.replace(".prompt", "")
|
|
86
|
+
|
|
87
|
+
if name in valid_names:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
content = file.read_text(encoding="utf-8")
|
|
92
|
+
if "This prompt delegates to the agent skill at" in content:
|
|
93
|
+
file.unlink()
|
|
94
|
+
removed.append(file)
|
|
95
|
+
except (OSError, UnicodeDecodeError):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return removed
|
|
99
|
+
|
|
100
|
+
def generate_all(
|
|
101
|
+
self,
|
|
102
|
+
skills: list[SkillInfo],
|
|
103
|
+
output_dir: Path,
|
|
104
|
+
exclude: set[str] | None = None,
|
|
105
|
+
copy: bool = False,
|
|
106
|
+
) -> tuple[list[Path], list[Path]]:
|
|
107
|
+
"""Generate prompt files and update instructions file.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
skills: List of skills to include.
|
|
111
|
+
output_dir: Base output directory.
|
|
112
|
+
exclude: Set of skill names to exclude.
|
|
113
|
+
copy: If True, copy skills locally and use relative paths.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Tuple of (generated files, removed stale files).
|
|
117
|
+
"""
|
|
118
|
+
exclude = exclude or set()
|
|
119
|
+
|
|
120
|
+
# Generate prompt files in .github/prompts/
|
|
121
|
+
prompts_dir = self.resolve_output_dir(output_dir)
|
|
122
|
+
prompts_dir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
active_skills = [s for s in skills if s.name not in exclude]
|
|
125
|
+
|
|
126
|
+
# Copy skills if requested
|
|
127
|
+
if copy:
|
|
128
|
+
skills_dir = self.get_skills_dir(output_dir)
|
|
129
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
for skill in active_skills:
|
|
131
|
+
self.copy_skill(skill, skills_dir)
|
|
132
|
+
|
|
133
|
+
generated = []
|
|
134
|
+
valid_names = set()
|
|
135
|
+
|
|
136
|
+
for skill in active_skills:
|
|
137
|
+
valid_names.add(skill.name)
|
|
138
|
+
path = self.generate(skill, prompts_dir, copy=copy, base_output_dir=output_dir)
|
|
139
|
+
generated.append(path)
|
|
140
|
+
|
|
141
|
+
removed = self.cleanup_stale(prompts_dir, valid_names)
|
|
142
|
+
|
|
143
|
+
# Update .github/copilot-instructions.md with skill index
|
|
144
|
+
github_dir = prompts_dir.parent # .github/
|
|
145
|
+
instructions_file = github_dir / "copilot-instructions.md"
|
|
146
|
+
|
|
147
|
+
if active_skills:
|
|
148
|
+
self._update_instructions_file(instructions_file, active_skills)
|
|
149
|
+
generated.append(instructions_file)
|
|
150
|
+
elif instructions_file.exists():
|
|
151
|
+
self._remove_managed_section(instructions_file)
|
|
152
|
+
|
|
153
|
+
return generated, removed
|
|
154
|
+
|
|
155
|
+
def _update_instructions_file(self, file_path: Path, skills: list[SkillInfo]) -> None:
|
|
156
|
+
"""Update or create the copilot-instructions.md file."""
|
|
157
|
+
skills_content = self._build_skills_section(skills)
|
|
158
|
+
|
|
159
|
+
if file_path.exists():
|
|
160
|
+
self._update_managed_section(file_path, skills_content)
|
|
161
|
+
else:
|
|
162
|
+
content = f"""# Copilot Instructions
|
|
163
|
+
|
|
164
|
+
{self.MARKER}
|
|
165
|
+
{skills_content}
|
|
166
|
+
{self.MARKER_END}
|
|
167
|
+
"""
|
|
168
|
+
file_path.write_text(content, encoding="utf-8")
|
|
169
|
+
|
|
170
|
+
def _build_skills_section(self, skills: list[SkillInfo]) -> str:
|
|
171
|
+
"""Build the managed skills section content."""
|
|
172
|
+
lines = ["## Available Skills", ""]
|
|
173
|
+
lines.append("The following agent skills are available. Invoke them by typing `/<skill-name>` in chat.")
|
|
174
|
+
lines.append("")
|
|
175
|
+
|
|
176
|
+
for skill in sorted(skills, key=lambda s: s.name):
|
|
177
|
+
desc = skill.description or "(no description)"
|
|
178
|
+
lines.append(f"- **/{skill.name}** — {desc}")
|
|
179
|
+
|
|
180
|
+
lines.append("")
|
|
181
|
+
return "\n".join(lines)
|
|
182
|
+
|
|
183
|
+
def _update_managed_section(self, file_path: Path, new_content: str) -> None:
|
|
184
|
+
"""Update the managed section in an existing file."""
|
|
185
|
+
content = file_path.read_text(encoding="utf-8")
|
|
186
|
+
|
|
187
|
+
start_idx = content.find(self.MARKER)
|
|
188
|
+
end_idx = content.find(self.MARKER_END)
|
|
189
|
+
|
|
190
|
+
if start_idx != -1 and end_idx != -1:
|
|
191
|
+
before = content[:start_idx]
|
|
192
|
+
after = content[end_idx + len(self.MARKER_END) :]
|
|
193
|
+
new_full = f"{before}{self.MARKER}\n{new_content}\n{self.MARKER_END}{after}"
|
|
194
|
+
else:
|
|
195
|
+
new_full = f"{content.rstrip()}\n\n{self.MARKER}\n{new_content}\n{self.MARKER_END}\n"
|
|
196
|
+
|
|
197
|
+
file_path.write_text(new_full, encoding="utf-8")
|
|
198
|
+
|
|
199
|
+
def _remove_managed_section(self, file_path: Path) -> None:
|
|
200
|
+
"""Remove the managed section from an existing file."""
|
|
201
|
+
content = file_path.read_text(encoding="utf-8")
|
|
202
|
+
|
|
203
|
+
start_idx = content.find(self.MARKER)
|
|
204
|
+
end_idx = content.find(self.MARKER_END)
|
|
205
|
+
|
|
206
|
+
if start_idx != -1 and end_idx != -1:
|
|
207
|
+
before = content[:start_idx].rstrip()
|
|
208
|
+
after = content[end_idx + len(self.MARKER_END) :].lstrip()
|
|
209
|
+
new_content = f"{before}\n{after}".strip() + "\n"
|
|
210
|
+
file_path.write_text(new_content, encoding="utf-8")
|
adapters/cursor.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Cursor adapter for generating .cursor/commands/*.md files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from adapters.base import BaseAdapter, SkillInfo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CursorAdapter(BaseAdapter):
|
|
9
|
+
"""Adapter for generating Cursor command files."""
|
|
10
|
+
|
|
11
|
+
target_name = "cursor"
|
|
12
|
+
target_subdir = ".cursor/commands"
|
|
13
|
+
|
|
14
|
+
def generate(
|
|
15
|
+
self, skill: SkillInfo, output_dir: Path, copy: bool = False, base_output_dir: Path | None = None
|
|
16
|
+
) -> Path:
|
|
17
|
+
"""Generate a Cursor command file for a skill.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
skill: Skill information.
|
|
21
|
+
output_dir: Resolved output directory (.cursor/commands/).
|
|
22
|
+
copy: If True, use relative paths to local skill copies.
|
|
23
|
+
base_output_dir: Base output directory (for computing relative paths).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Path to the generated file.
|
|
27
|
+
"""
|
|
28
|
+
output_file = output_dir / f"{skill.name}.md"
|
|
29
|
+
|
|
30
|
+
skill_path = self.get_skill_path(skill, base_output_dir or output_dir.parent.parent, copy)
|
|
31
|
+
|
|
32
|
+
content = f"""# {skill.name}
|
|
33
|
+
|
|
34
|
+
{skill.description}
|
|
35
|
+
|
|
36
|
+
This command delegates to the agent skill at `{skill_path}/`.
|
|
37
|
+
|
|
38
|
+
## Skill Location
|
|
39
|
+
|
|
40
|
+
- **Path:** `{skill_path}/`
|
|
41
|
+
- **Manifest:** `{skill_path}/SKILL.md`
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
output_file.write_text(content, encoding="utf-8")
|
|
45
|
+
return output_file
|
|
46
|
+
|
|
47
|
+
def cleanup_stale(self, output_dir: Path, valid_names: set[str]) -> list[Path]:
|
|
48
|
+
"""Remove stale Cursor command files.
|
|
49
|
+
|
|
50
|
+
Only removes files that look like generated skill commands.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
output_dir: Output directory to clean.
|
|
54
|
+
valid_names: Set of valid skill names (files to keep).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of removed file paths.
|
|
58
|
+
"""
|
|
59
|
+
removed = []
|
|
60
|
+
|
|
61
|
+
if not output_dir.is_dir():
|
|
62
|
+
return removed
|
|
63
|
+
|
|
64
|
+
for file in output_dir.glob("*.md"):
|
|
65
|
+
name = file.stem
|
|
66
|
+
|
|
67
|
+
if name in valid_names:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
content = file.read_text(encoding="utf-8")
|
|
72
|
+
if "This command delegates to the agent skill at" in content:
|
|
73
|
+
file.unlink()
|
|
74
|
+
removed.append(file)
|
|
75
|
+
except (OSError, UnicodeDecodeError):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
return removed
|
adapters/windsurf.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Windsurf adapter for generating .windsurf/workflows/*.md files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from adapters.base import BaseAdapter, SkillInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WindsurfAdapter(BaseAdapter):
|
|
10
|
+
"""Adapter for generating Windsurf workflow files."""
|
|
11
|
+
|
|
12
|
+
target_name = "windsurf"
|
|
13
|
+
target_subdir = ".windsurf/workflows"
|
|
14
|
+
|
|
15
|
+
def generate(
|
|
16
|
+
self, skill: SkillInfo, output_dir: Path, copy: bool = False, base_output_dir: Path | None = None
|
|
17
|
+
) -> Path:
|
|
18
|
+
"""Generate a Windsurf workflow file for a skill.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
skill: Skill information.
|
|
22
|
+
output_dir: Resolved output directory (.windsurf/workflows/).
|
|
23
|
+
copy: If True, use relative paths to local skill copies.
|
|
24
|
+
base_output_dir: Base output directory (for computing relative paths).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Path to the generated file.
|
|
28
|
+
"""
|
|
29
|
+
output_file = output_dir / f"{skill.name}.md"
|
|
30
|
+
|
|
31
|
+
skill_path = self.get_skill_path(skill, base_output_dir or output_dir.parent.parent, copy)
|
|
32
|
+
desc_yaml = json.dumps(skill.description)
|
|
33
|
+
|
|
34
|
+
content = f"""---
|
|
35
|
+
description: {desc_yaml}
|
|
36
|
+
auto_execution_mode: 1
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
# {skill.name}
|
|
40
|
+
|
|
41
|
+
This workflow delegates to the agent skill at `{skill_path}/`.
|
|
42
|
+
|
|
43
|
+
## Skill Location
|
|
44
|
+
|
|
45
|
+
- **Path:** `{skill_path}/`
|
|
46
|
+
- **Manifest:** `{skill_path}/SKILL.md`
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
output_file.write_text(content, encoding="utf-8")
|
|
50
|
+
return output_file
|
|
51
|
+
|
|
52
|
+
def cleanup_stale(self, output_dir: Path, valid_names: set[str]) -> list[Path]:
|
|
53
|
+
"""Remove stale Windsurf workflow files.
|
|
54
|
+
|
|
55
|
+
Only removes files that look like generated skill workflows.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
output_dir: Output directory to clean.
|
|
59
|
+
valid_names: Set of valid skill names (files to keep).
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of removed file paths.
|
|
63
|
+
"""
|
|
64
|
+
removed = []
|
|
65
|
+
|
|
66
|
+
if not output_dir.is_dir():
|
|
67
|
+
return removed
|
|
68
|
+
|
|
69
|
+
for file in output_dir.glob("*.md"):
|
|
70
|
+
name = file.stem
|
|
71
|
+
|
|
72
|
+
if name in valid_names:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
content = file.read_text(encoding="utf-8")
|
|
77
|
+
if "This workflow delegates to the agent skill at" in content:
|
|
78
|
+
file.unlink()
|
|
79
|
+
removed.append(file)
|
|
80
|
+
except (OSError, UnicodeDecodeError):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
return removed
|
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"]
|