oasr 0.3.4__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/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
cli.py ADDED
@@ -0,0 +1,94 @@
1
+ """CLI entry point (argparse wiring + dispatch).
2
+
3
+ Command implementations live under `src/commands/`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from commands import adapter, clean, diff, find, registry, sync, update, use, validate
14
+ from commands import help as help_cmd
15
+
16
+ __version__ = "0.3.4"
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ """Main CLI entry point."""
21
+ parser = create_parser()
22
+ args = parser.parse_args(argv)
23
+
24
+ if not hasattr(args, "func"):
25
+ parser.print_help()
26
+ return 1
27
+
28
+ try:
29
+ return args.func(args)
30
+ except KeyboardInterrupt:
31
+ print("\nInterrupted.", file=sys.stderr)
32
+ return 130
33
+ except Exception as e:
34
+ if args.json if hasattr(args, "json") else False:
35
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
36
+ else:
37
+ print(f"Error: {e}", file=sys.stderr)
38
+ return 3
39
+
40
+
41
+ def create_parser() -> argparse.ArgumentParser:
42
+ """Create the argument parser."""
43
+ parser = argparse.ArgumentParser(
44
+ prog="oasr",
45
+ description="Open Agent Skills Registry - Manage agent skills across IDE integrations.",
46
+ )
47
+ parser.add_argument(
48
+ "--version",
49
+ action="version",
50
+ version=f"%(prog)s {__version__}",
51
+ )
52
+ parser.add_argument(
53
+ "--config",
54
+ type=Path,
55
+ help="Override config file path",
56
+ )
57
+ parser.add_argument(
58
+ "--json",
59
+ action="store_true",
60
+ help="Output in JSON format",
61
+ )
62
+ parser.add_argument(
63
+ "--quiet",
64
+ action="store_true",
65
+ help="Suppress info and warnings",
66
+ )
67
+
68
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
69
+
70
+ # New taxonomy (v0.3.0)
71
+ registry.register(subparsers) # Registry operations (add, rm, sync, list)
72
+ diff.register(subparsers) # Show tracked skill status
73
+ sync.register(subparsers) # Refresh tracked skills
74
+
75
+ # Unchanged commands
76
+ use.register(subparsers)
77
+ find.register(subparsers)
78
+ validate.register(subparsers)
79
+ clean.register(subparsers)
80
+ adapter.register(subparsers)
81
+ update.register(subparsers)
82
+
83
+ # Import and register info command
84
+ from commands import info as info_cmd
85
+
86
+ info_cmd.register(subparsers)
87
+
88
+ help_cmd.register(subparsers, parser)
89
+
90
+ return parser
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())
commands/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Command modules for the ASR CLI.
2
+
3
+ Each command module exposes:
4
+ - register(subparsers): attach argparse parsers
5
+ - run(args): execute command
6
+ """