agentops-accelerator 0.3.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.
- agentops/__init__.py +10 -0
- agentops/__main__.py +6 -0
- agentops/agent/__init__.py +12 -0
- agentops/agent/_legacy_ids.py +92 -0
- agentops/agent/analyzer.py +207 -0
- agentops/agent/checks/__init__.py +1 -0
- agentops/agent/checks/catalog.py +880 -0
- agentops/agent/checks/errors.py +279 -0
- agentops/agent/checks/foundry_config.py +75 -0
- agentops/agent/checks/latency.py +84 -0
- agentops/agent/checks/opex.py +157 -0
- agentops/agent/checks/opex_workspace.py +874 -0
- agentops/agent/checks/posture.py +36 -0
- agentops/agent/checks/posture_rules/__init__.py +53 -0
- agentops/agent/checks/posture_rules/content_filter.py +59 -0
- agentops/agent/checks/posture_rules/diagnostics.py +74 -0
- agentops/agent/checks/posture_rules/local_auth.py +55 -0
- agentops/agent/checks/posture_rules/managed_identity.py +59 -0
- agentops/agent/checks/posture_rules/network.py +68 -0
- agentops/agent/checks/regression.py +78 -0
- agentops/agent/checks/release_readiness.py +182 -0
- agentops/agent/checks/safety.py +247 -0
- agentops/agent/checks/spec_conformance.py +375 -0
- agentops/agent/cockpit.py +5159 -0
- agentops/agent/config.py +240 -0
- agentops/agent/findings.py +113 -0
- agentops/agent/history.py +142 -0
- agentops/agent/knowledge/__init__.py +182 -0
- agentops/agent/knowledge/waf-checklist.csv +39 -0
- agentops/agent/llm_assist/__init__.py +16 -0
- agentops/agent/llm_assist/_base.py +124 -0
- agentops/agent/llm_assist/_bundle_rule.py +154 -0
- agentops/agent/llm_assist/_client.py +347 -0
- agentops/agent/llm_assist/_dataset_rules.py +191 -0
- agentops/agent/llm_assist/_engine.py +106 -0
- agentops/agent/llm_assist/_prompt_rules.py +291 -0
- agentops/agent/llm_assist/_spec_rules.py +235 -0
- agentops/agent/production_telemetry.py +430 -0
- agentops/agent/report.py +207 -0
- agentops/agent/server/__init__.py +1 -0
- agentops/agent/server/app.py +84 -0
- agentops/agent/server/auth.py +94 -0
- agentops/agent/server/chat.py +44 -0
- agentops/agent/server/protocol.py +72 -0
- agentops/agent/sources/__init__.py +1 -0
- agentops/agent/sources/azure_monitor.py +523 -0
- agentops/agent/sources/azure_resources.py +602 -0
- agentops/agent/sources/foundry_control.py +174 -0
- agentops/agent/sources/results_history.py +494 -0
- agentops/agent/sources/spec_detectors/__init__.py +42 -0
- agentops/agent/sources/spec_detectors/_base.py +58 -0
- agentops/agent/sources/spec_detectors/agents_md.py +75 -0
- agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
- agentops/agent/time_range.py +117 -0
- agentops/cli/__init__.py +1 -0
- agentops/cli/app.py +4823 -0
- agentops/core/__init__.py +1 -0
- agentops/core/agentops_config.py +592 -0
- agentops/core/config_loader.py +22 -0
- agentops/core/evaluators.py +480 -0
- agentops/core/release_evidence.py +56 -0
- agentops/core/results.py +117 -0
- agentops/mcp/__init__.py +10 -0
- agentops/mcp/server.py +232 -0
- agentops/pipeline/__init__.py +8 -0
- agentops/pipeline/cloud_results.py +189 -0
- agentops/pipeline/cloud_runner.py +901 -0
- agentops/pipeline/comparison.py +108 -0
- agentops/pipeline/diagnostics.py +51 -0
- agentops/pipeline/invocations.py +535 -0
- agentops/pipeline/official_eval.py +414 -0
- agentops/pipeline/orchestrator.py +775 -0
- agentops/pipeline/prompt_deploy.py +377 -0
- agentops/pipeline/publisher.py +121 -0
- agentops/pipeline/reporter.py +202 -0
- agentops/pipeline/runtime.py +409 -0
- agentops/pipeline/thresholds.py +84 -0
- agentops/services/__init__.py +1 -0
- agentops/services/cicd.py +720 -0
- agentops/services/eval_analysis.py +848 -0
- agentops/services/evidence_pack.py +757 -0
- agentops/services/initializer.py +86 -0
- agentops/services/preflight.py +470 -0
- agentops/services/setup_wizard.py +709 -0
- agentops/services/skills.py +643 -0
- agentops/services/trace_promotion.py +300 -0
- agentops/services/workflow_analysis.py +1129 -0
- agentops/templates/.gitignore +15 -0
- agentops/templates/__init__.py +1 -0
- agentops/templates/agent-server/Dockerfile +23 -0
- agentops/templates/agent-server/README.md +61 -0
- agentops/templates/agent-server/main.bicep +94 -0
- agentops/templates/agent.yaml +87 -0
- agentops/templates/agentops.yaml +58 -0
- agentops/templates/foundry.svg +71 -0
- agentops/templates/icon.png +0 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
- agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
- agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
- agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
- agentops/templates/project.gitignore +36 -0
- agentops/templates/sample-traces.jsonl +3 -0
- agentops/templates/skills/agentops-agent/SKILL.md +137 -0
- agentops/templates/skills/agentops-config/SKILL.md +113 -0
- agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
- agentops/templates/skills/agentops-eval/SKILL.md +189 -0
- agentops/templates/skills/agentops-report/SKILL.md +71 -0
- agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
- agentops/templates/smoke.jsonl +3 -0
- agentops/templates/waf-checklist.README.md +84 -0
- agentops/templates/waf-checklist.csv +22 -0
- agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
- agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
- agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
- agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
- agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
- agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
- agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
- agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
- agentops/templates/workflows/agentops-pr.yml +148 -0
- agentops/templates/workflows/agentops-watchdog.yml +122 -0
- agentops/utils/__init__.py +1 -0
- agentops/utils/azd_env.py +435 -0
- agentops/utils/azure_endpoints.py +62 -0
- agentops/utils/colors.py +47 -0
- agentops/utils/dotenv_loader.py +105 -0
- agentops/utils/foundry_discovery.py +229 -0
- agentops/utils/logging.py +59 -0
- agentops/utils/telemetry.py +554 -0
- agentops/utils/yaml.py +36 -0
- agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
- agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
- agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
- agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
- agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
- agentops_accelerator-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""Coding agent skills installation and registration service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import tarfile
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Dict, List
|
|
15
|
+
|
|
16
|
+
_TEMPLATE_PACKAGE = "agentops.templates"
|
|
17
|
+
|
|
18
|
+
_SKILLS: tuple[str, ...] = (
|
|
19
|
+
"skills/agentops-eval/SKILL.md",
|
|
20
|
+
"skills/agentops-config/SKILL.md",
|
|
21
|
+
"skills/agentops-dataset/SKILL.md",
|
|
22
|
+
"skills/agentops-report/SKILL.md",
|
|
23
|
+
"skills/agentops-workflow/SKILL.md",
|
|
24
|
+
"skills/agentops-agent/SKILL.md",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_PLATFORM_CONFIGS: Dict[str, Dict[str, str]] = {
|
|
28
|
+
"copilot": {
|
|
29
|
+
"target_dir": ".github/skills",
|
|
30
|
+
"file_pattern": "{skill_name}/SKILL.md",
|
|
31
|
+
},
|
|
32
|
+
"claude": {
|
|
33
|
+
"target_dir": ".claude/commands",
|
|
34
|
+
"file_pattern": "{skill_name}.md",
|
|
35
|
+
},
|
|
36
|
+
"cursor": {
|
|
37
|
+
"target_dir": ".github/skills",
|
|
38
|
+
"file_pattern": "{skill_name}/SKILL.md",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Registration markers and content blocks
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
_COPILOT_MARKER_START = "<!-- agentops-skills-start -->"
|
|
49
|
+
_COPILOT_MARKER_END = "<!-- agentops-skills-end -->"
|
|
50
|
+
|
|
51
|
+
_COPILOT_BLOCK = f"""{_COPILOT_MARKER_START}
|
|
52
|
+
## AgentOps Evaluation & Operations
|
|
53
|
+
|
|
54
|
+
This project uses AgentOps for agent evaluation and benchmarking. When the
|
|
55
|
+
user asks about any of the topics below, read the corresponding skill file
|
|
56
|
+
**before** responding and follow its workflow step by step.
|
|
57
|
+
|
|
58
|
+
| Topic | Skill File | Trigger phrases |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| Run evaluations, benchmark, compare runs | `.github/skills/agentops-eval/SKILL.md` | "run eval", "evaluate", "benchmark", "compare runs" |
|
|
61
|
+
| Generate agentops.yaml configuration | `.github/skills/agentops-config/SKILL.md` | "configure", "agentops.yaml", "set up eval" |
|
|
62
|
+
| Generate evaluation datasets | `.github/skills/agentops-dataset/SKILL.md` | "create dataset", "generate test data", "JSONL" |
|
|
63
|
+
| Interpret and regenerate reports | `.github/skills/agentops-report/SKILL.md` | "report", "results", "explain scores" |
|
|
64
|
+
| CI/CD workflow setup | `.github/skills/agentops-workflow/SKILL.md` | "CI", "workflow", "pipeline", "GitHub Actions" |
|
|
65
|
+
| Watchdog analysis | `.github/skills/agentops-agent/SKILL.md` | "watchdog", "agent analyze", "production health", "latency spikes" |
|
|
66
|
+
{_COPILOT_MARKER_END}"""
|
|
67
|
+
|
|
68
|
+
_CURSOR_MDC = """\
|
|
69
|
+
---
|
|
70
|
+
description: AgentOps evaluation and benchmarking tools
|
|
71
|
+
globs: "**"
|
|
72
|
+
alwaysApply: true
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
When the user asks about evaluations, benchmarks, datasets, or reports,
|
|
76
|
+
read the corresponding skill file and follow its workflow step by step.
|
|
77
|
+
|
|
78
|
+
| Topic | Skill File |
|
|
79
|
+
|---|---|
|
|
80
|
+
| Run evaluations, benchmark, compare runs | `.github/skills/agentops-eval/SKILL.md` |
|
|
81
|
+
| Generate agentops.yaml configuration | `.github/skills/agentops-config/SKILL.md` |
|
|
82
|
+
| Generate evaluation datasets | `.github/skills/agentops-dataset/SKILL.md` |
|
|
83
|
+
| Interpret and regenerate reports | `.github/skills/agentops-report/SKILL.md` |
|
|
84
|
+
| CI/CD workflow setup | `.github/skills/agentops-workflow/SKILL.md` |
|
|
85
|
+
| Watchdog analysis | `.github/skills/agentops-agent/SKILL.md` |
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class SkillsInstallResult:
|
|
91
|
+
"""Result of installing coding agent skills.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
platforms: Platform names that were targeted.
|
|
95
|
+
created_files: Paths of newly created files.
|
|
96
|
+
overwritten_files: Paths of files that were overwritten.
|
|
97
|
+
skipped_files: Paths of files that already existed and were skipped.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
platforms: List[str] = field(default_factory=list)
|
|
101
|
+
created_files: List[Path] = field(default_factory=list)
|
|
102
|
+
overwritten_files: List[Path] = field(default_factory=list)
|
|
103
|
+
skipped_files: List[Path] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def detect_platforms(directory: Path) -> list[str]:
|
|
107
|
+
"""Detect coding agent platforms present in the project.
|
|
108
|
+
|
|
109
|
+
Returns a list of platform identifiers (e.g. ``["copilot"]``,
|
|
110
|
+
``["claude"]``, ``["copilot", "claude"]``). Returns an empty list
|
|
111
|
+
when no platform indicators are found.
|
|
112
|
+
"""
|
|
113
|
+
resolved = directory.resolve()
|
|
114
|
+
platforms: list[str] = []
|
|
115
|
+
|
|
116
|
+
if (resolved / ".claude").exists() or (resolved / "CLAUDE.md").exists():
|
|
117
|
+
platforms.append("claude")
|
|
118
|
+
|
|
119
|
+
if (
|
|
120
|
+
(resolved / ".github" / "copilot-instructions.md").exists()
|
|
121
|
+
or (resolved / ".github" / "copilot_instructions.md").exists()
|
|
122
|
+
or (resolved / ".github" / "skills").exists()
|
|
123
|
+
):
|
|
124
|
+
platforms.append("copilot")
|
|
125
|
+
|
|
126
|
+
if (
|
|
127
|
+
(resolved / ".cursor" / "rules").exists()
|
|
128
|
+
or (resolved / ".cursorrules").exists()
|
|
129
|
+
):
|
|
130
|
+
platforms.append("cursor")
|
|
131
|
+
|
|
132
|
+
return platforms
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _strip_yaml_frontmatter(content: str) -> str:
|
|
136
|
+
"""Remove YAML frontmatter delimited by ``---`` from content."""
|
|
137
|
+
return _FRONTMATTER_RE.sub("", content)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _transform_content(content: str, platform: str) -> str:
|
|
141
|
+
"""Apply platform-specific content transformations."""
|
|
142
|
+
if platform == "claude":
|
|
143
|
+
return _strip_yaml_frontmatter(content)
|
|
144
|
+
return content
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def install_skills(
|
|
148
|
+
directory: Path,
|
|
149
|
+
platforms: list[str],
|
|
150
|
+
force: bool = False,
|
|
151
|
+
) -> SkillsInstallResult:
|
|
152
|
+
"""Install packaged coding agent skills for the specified platforms.
|
|
153
|
+
|
|
154
|
+
Reads skill templates from the package and writes them to the
|
|
155
|
+
platform-specific directories in the target *directory*.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
directory: Root directory of the consumer repository.
|
|
159
|
+
platforms: List of platform identifiers (e.g. ``["copilot"]``).
|
|
160
|
+
force: When True, overwrite existing skill files.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
SkillsInstallResult with paths of created, overwritten, or skipped files.
|
|
164
|
+
"""
|
|
165
|
+
result = SkillsInstallResult(platforms=list(platforms))
|
|
166
|
+
templates_root = files(_TEMPLATE_PACKAGE)
|
|
167
|
+
resolved = directory.resolve()
|
|
168
|
+
|
|
169
|
+
for platform in platforms:
|
|
170
|
+
config = _PLATFORM_CONFIGS.get(platform)
|
|
171
|
+
if not config:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
target_dir = resolved / config["target_dir"]
|
|
175
|
+
|
|
176
|
+
for skill_path in _SKILLS:
|
|
177
|
+
# "skills/agentops-eval/SKILL.md" → "agentops-eval"
|
|
178
|
+
skill_name = Path(skill_path).parent.name
|
|
179
|
+
|
|
180
|
+
dest_relative = config["file_pattern"].format(skill_name=skill_name)
|
|
181
|
+
dest = target_dir / dest_relative
|
|
182
|
+
existed = dest.exists()
|
|
183
|
+
|
|
184
|
+
if existed and not force:
|
|
185
|
+
result.skipped_files.append(dest)
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
raw = templates_root.joinpath(skill_path).read_text(encoding="utf-8")
|
|
190
|
+
content = _transform_content(raw, platform)
|
|
191
|
+
dest.write_text(content, encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
if existed:
|
|
194
|
+
result.overwritten_files.append(dest)
|
|
195
|
+
else:
|
|
196
|
+
result.created_files.append(dest)
|
|
197
|
+
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# GitHub-based skill installation
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
# Allowed sub-directories within a skill folder (agentskills.io spec).
|
|
206
|
+
_ALLOWED_SKILL_DIRS = {"references", "scripts", "assets"}
|
|
207
|
+
|
|
208
|
+
# Directories skipped by default for security (opt-in only).
|
|
209
|
+
_RESTRICTED_DIRS = {"scripts"}
|
|
210
|
+
|
|
211
|
+
_GITHUB_REF_RE = re.compile(
|
|
212
|
+
r"^(?:github:)?"
|
|
213
|
+
r"(?P<owner>[A-Za-z0-9._-]+)"
|
|
214
|
+
r"/(?P<repo>[A-Za-z0-9._-]+)"
|
|
215
|
+
r"(?:@(?P<ref>[A-Za-z0-9._/-]+))?$"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
_PROVENANCE_FILE = ".installed-from.json"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass
|
|
222
|
+
class GitHubSkillRef:
|
|
223
|
+
"""Parsed GitHub skill reference."""
|
|
224
|
+
|
|
225
|
+
owner: str
|
|
226
|
+
repo: str
|
|
227
|
+
ref: str # branch, tag, or commit SHA
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_github_ref(source: str) -> GitHubSkillRef:
|
|
231
|
+
"""Parse ``github:org/repo@ref`` or ``org/repo`` into components.
|
|
232
|
+
|
|
233
|
+
Raises ValueError on invalid input.
|
|
234
|
+
"""
|
|
235
|
+
m = _GITHUB_REF_RE.match(source.strip())
|
|
236
|
+
if not m:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"Invalid GitHub skill reference: '{source}'. "
|
|
239
|
+
"Expected format: github:org/repo or org/repo[@ref]"
|
|
240
|
+
)
|
|
241
|
+
return GitHubSkillRef(
|
|
242
|
+
owner=m.group("owner"),
|
|
243
|
+
repo=m.group("repo"),
|
|
244
|
+
ref=m.group("ref") or "main",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _validate_skill_name(name: str) -> str:
|
|
249
|
+
"""Validate and sanitize a skill name from SKILL.md frontmatter.
|
|
250
|
+
|
|
251
|
+
Raises ValueError if the name contains path traversal or invalid chars.
|
|
252
|
+
"""
|
|
253
|
+
if not name or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"Invalid skill name: '{name}'. "
|
|
256
|
+
"Must be lowercase alphanumeric with single hyphens, "
|
|
257
|
+
"e.g. 'pptx-designer'."
|
|
258
|
+
)
|
|
259
|
+
if ".." in name or "/" in name or "\\" in name:
|
|
260
|
+
raise ValueError(f"Skill name contains path traversal: '{name}'")
|
|
261
|
+
return name
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_skill_frontmatter(content: str) -> dict[str, str]:
|
|
265
|
+
"""Extract YAML frontmatter fields from a SKILL.md file.
|
|
266
|
+
|
|
267
|
+
Returns a dict with at least ``name`` and ``description`` keys.
|
|
268
|
+
Uses simple line parsing to avoid a YAML dependency in this module.
|
|
269
|
+
"""
|
|
270
|
+
if not content.startswith("---"):
|
|
271
|
+
raise ValueError("SKILL.md is missing YAML frontmatter (must start with ---).")
|
|
272
|
+
|
|
273
|
+
lines = content.split("\n")
|
|
274
|
+
end_idx = None
|
|
275
|
+
for i, line in enumerate(lines[1:], 1):
|
|
276
|
+
if line.strip() == "---":
|
|
277
|
+
end_idx = i
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if end_idx is None:
|
|
281
|
+
raise ValueError("SKILL.md has unclosed YAML frontmatter.")
|
|
282
|
+
|
|
283
|
+
meta: dict[str, str] = {}
|
|
284
|
+
current_key = ""
|
|
285
|
+
for line in lines[1:end_idx]:
|
|
286
|
+
if line.startswith(" ") and current_key:
|
|
287
|
+
# Continuation of multiline value
|
|
288
|
+
meta[current_key] = meta.get(current_key, "") + " " + line.strip()
|
|
289
|
+
continue
|
|
290
|
+
if ":" in line:
|
|
291
|
+
key, _, val = line.partition(":")
|
|
292
|
+
key = key.strip()
|
|
293
|
+
val = val.strip().strip(">").strip('"').strip("'").strip()
|
|
294
|
+
if key:
|
|
295
|
+
current_key = key
|
|
296
|
+
meta[key] = val
|
|
297
|
+
|
|
298
|
+
if "name" not in meta:
|
|
299
|
+
raise ValueError("SKILL.md frontmatter is missing required 'name' field.")
|
|
300
|
+
if "description" not in meta:
|
|
301
|
+
raise ValueError("SKILL.md frontmatter is missing required 'description' field.")
|
|
302
|
+
|
|
303
|
+
return meta
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _fetch_github_tarball(ref: GitHubSkillRef) -> bytes:
|
|
307
|
+
"""Download a GitHub repo tarball for the given ref.
|
|
308
|
+
|
|
309
|
+
Uses ``GITHUB_TOKEN`` or ``GH_TOKEN`` env var if available.
|
|
310
|
+
"""
|
|
311
|
+
import os
|
|
312
|
+
|
|
313
|
+
url = f"https://api.github.com/repos/{ref.owner}/{ref.repo}/tarball/{ref.ref}"
|
|
314
|
+
|
|
315
|
+
headers: dict[str, str] = {"Accept": "application/vnd.github+json"}
|
|
316
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
317
|
+
if token:
|
|
318
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
319
|
+
|
|
320
|
+
req = urllib.request.Request(url, headers=headers)
|
|
321
|
+
try:
|
|
322
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
323
|
+
return resp.read()
|
|
324
|
+
except urllib.error.HTTPError as e:
|
|
325
|
+
if e.code == 404:
|
|
326
|
+
raise ValueError(
|
|
327
|
+
f"GitHub repository not found: {ref.owner}/{ref.repo}@{ref.ref}"
|
|
328
|
+
) from e
|
|
329
|
+
if e.code == 403:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"GitHub API rate limit or access denied for {ref.owner}/{ref.repo}. "
|
|
332
|
+
"Set GITHUB_TOKEN env var for authenticated access."
|
|
333
|
+
) from e
|
|
334
|
+
raise ValueError(
|
|
335
|
+
f"GitHub API error ({e.code}): {e.reason}"
|
|
336
|
+
) from e
|
|
337
|
+
except urllib.error.URLError as e:
|
|
338
|
+
raise ValueError(f"Network error fetching {ref.owner}/{ref.repo}: {e}") from e
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _extract_skill_from_tarball(
|
|
342
|
+
tarball: bytes,
|
|
343
|
+
repo_name: str,
|
|
344
|
+
) -> tuple[dict[str, str], dict[str, bytes]]:
|
|
345
|
+
"""Extract a single skill from a GitHub repo tarball.
|
|
346
|
+
|
|
347
|
+
Returns (frontmatter_metadata, {relative_path: content_bytes}).
|
|
348
|
+
|
|
349
|
+
Searches for the skill directory following agentskills.io convention:
|
|
350
|
+
1. ``{repo_name}/SKILL.md`` (skill dir = repo name)
|
|
351
|
+
2. Any ``*/SKILL.md`` at depth 1 from repo root
|
|
352
|
+
3. ``SKILL.md`` at repo root
|
|
353
|
+
|
|
354
|
+
Raises ValueError if no SKILL.md is found or multiple candidates exist.
|
|
355
|
+
"""
|
|
356
|
+
with tarfile.open(fileobj=io.BytesIO(tarball), mode="r:gz") as tar:
|
|
357
|
+
members = tar.getnames()
|
|
358
|
+
|
|
359
|
+
# GitHub tarballs have a prefix like "owner-repo-sha/"
|
|
360
|
+
prefix = ""
|
|
361
|
+
for name in members:
|
|
362
|
+
if "/" in name:
|
|
363
|
+
prefix = name.split("/")[0] + "/"
|
|
364
|
+
break
|
|
365
|
+
|
|
366
|
+
# Find SKILL.md candidates
|
|
367
|
+
candidates: list[str] = []
|
|
368
|
+
for name in members:
|
|
369
|
+
relative = name[len(prefix):] if name.startswith(prefix) else name
|
|
370
|
+
parts = PurePosixPath(relative).parts
|
|
371
|
+
if parts and parts[-1] == "SKILL.md":
|
|
372
|
+
if len(parts) <= 2:
|
|
373
|
+
candidates.append(relative)
|
|
374
|
+
|
|
375
|
+
if not candidates:
|
|
376
|
+
raise ValueError(
|
|
377
|
+
f"No SKILL.md found in {repo_name}. "
|
|
378
|
+
"The repository must contain a skill directory with a SKILL.md file "
|
|
379
|
+
"(agentskills.io standard)."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Prefer {repo_name}/SKILL.md, then first candidate
|
|
383
|
+
chosen = None
|
|
384
|
+
for c in candidates:
|
|
385
|
+
if c.startswith(repo_name + "/"):
|
|
386
|
+
chosen = c
|
|
387
|
+
break
|
|
388
|
+
if chosen is None:
|
|
389
|
+
if len(candidates) > 1:
|
|
390
|
+
dirs = [str(PurePosixPath(c).parent) for c in candidates]
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"Multiple skills found in {repo_name}: {', '.join(dirs)}. "
|
|
393
|
+
"Use github:org/repo with a repo that contains a single skill."
|
|
394
|
+
)
|
|
395
|
+
chosen = candidates[0]
|
|
396
|
+
|
|
397
|
+
skill_dir = str(PurePosixPath(chosen).parent)
|
|
398
|
+
if skill_dir == ".":
|
|
399
|
+
skill_dir = ""
|
|
400
|
+
|
|
401
|
+
# Read SKILL.md and parse frontmatter
|
|
402
|
+
skill_md_path = prefix + chosen
|
|
403
|
+
member = tar.getmember(skill_md_path)
|
|
404
|
+
f = tar.extractfile(member)
|
|
405
|
+
if f is None:
|
|
406
|
+
raise ValueError(f"Cannot read {skill_md_path}")
|
|
407
|
+
skill_md_content = f.read()
|
|
408
|
+
metadata = _parse_skill_frontmatter(skill_md_content.decode("utf-8"))
|
|
409
|
+
|
|
410
|
+
# Collect all files in the skill directory
|
|
411
|
+
skill_prefix = prefix + (skill_dir + "/" if skill_dir else "")
|
|
412
|
+
collected: dict[str, bytes] = {}
|
|
413
|
+
|
|
414
|
+
for member in tar.getmembers():
|
|
415
|
+
if not member.isfile():
|
|
416
|
+
continue
|
|
417
|
+
if not member.name.startswith(skill_prefix):
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
relative = member.name[len(skill_prefix):]
|
|
421
|
+
parts = PurePosixPath(relative).parts
|
|
422
|
+
|
|
423
|
+
if not parts:
|
|
424
|
+
continue
|
|
425
|
+
|
|
426
|
+
# Security: block path traversal
|
|
427
|
+
if any(p in ("..", "") for p in parts):
|
|
428
|
+
continue
|
|
429
|
+
if any(p.startswith(".") for p in parts):
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
# Allow SKILL.md at root, and files in allowed subdirs
|
|
433
|
+
if len(parts) == 1 and parts[0] == "SKILL.md":
|
|
434
|
+
collected[relative] = skill_md_content
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
top_dir = parts[0] if len(parts) > 1 else None
|
|
438
|
+
if top_dir and top_dir in _ALLOWED_SKILL_DIRS:
|
|
439
|
+
if top_dir in _RESTRICTED_DIRS:
|
|
440
|
+
continue # Skip scripts/ by default
|
|
441
|
+
f = tar.extractfile(member)
|
|
442
|
+
if f:
|
|
443
|
+
collected[relative] = f.read()
|
|
444
|
+
|
|
445
|
+
return metadata, collected
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def install_github_skill(
|
|
449
|
+
source: str,
|
|
450
|
+
directory: Path,
|
|
451
|
+
platforms: list[str],
|
|
452
|
+
force: bool = False,
|
|
453
|
+
) -> SkillsInstallResult:
|
|
454
|
+
"""Install a skill from a GitHub repository.
|
|
455
|
+
|
|
456
|
+
Downloads the repo archive, extracts the skill, validates it,
|
|
457
|
+
and installs to platform-specific directories.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
source: GitHub reference, e.g. ``github:org/repo``, ``org/repo@v1.0``.
|
|
461
|
+
directory: Root directory of the consumer repository.
|
|
462
|
+
platforms: Platform identifiers (e.g. ``["copilot"]``).
|
|
463
|
+
force: When True, overwrite existing skill files.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
SkillsInstallResult with paths of created, overwritten, or skipped files.
|
|
467
|
+
"""
|
|
468
|
+
ref = _parse_github_ref(source)
|
|
469
|
+
result = SkillsInstallResult(platforms=list(platforms))
|
|
470
|
+
resolved = directory.resolve()
|
|
471
|
+
|
|
472
|
+
# Fetch and extract
|
|
473
|
+
tarball = _fetch_github_tarball(ref)
|
|
474
|
+
metadata, skill_files = _extract_skill_from_tarball(tarball, ref.repo)
|
|
475
|
+
|
|
476
|
+
skill_name = _validate_skill_name(metadata["name"])
|
|
477
|
+
|
|
478
|
+
if not skill_files:
|
|
479
|
+
raise ValueError(f"No installable files found in {ref.owner}/{ref.repo}.")
|
|
480
|
+
|
|
481
|
+
# Install to each platform
|
|
482
|
+
for platform in platforms:
|
|
483
|
+
config = _PLATFORM_CONFIGS.get(platform)
|
|
484
|
+
if not config:
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
target_dir = resolved / config["target_dir"]
|
|
488
|
+
|
|
489
|
+
for relative_path, content_bytes in skill_files.items():
|
|
490
|
+
if relative_path == "SKILL.md":
|
|
491
|
+
# SKILL.md uses the platform file pattern
|
|
492
|
+
dest_relative = config["file_pattern"].format(skill_name=skill_name)
|
|
493
|
+
dest = target_dir / dest_relative
|
|
494
|
+
text_content = content_bytes.decode("utf-8")
|
|
495
|
+
text_content = _transform_content(text_content, platform)
|
|
496
|
+
write_bytes = text_content.encode("utf-8")
|
|
497
|
+
else:
|
|
498
|
+
# Reference/asset files go alongside the SKILL.md
|
|
499
|
+
if platform == "claude":
|
|
500
|
+
continue # Claude only gets the single .md file
|
|
501
|
+
skill_dest_dir = config["file_pattern"].format(
|
|
502
|
+
skill_name=skill_name
|
|
503
|
+
)
|
|
504
|
+
# e.g. "pptx-designer/SKILL.md" → "pptx-designer/"
|
|
505
|
+
skill_base = str(PurePosixPath(skill_dest_dir).parent)
|
|
506
|
+
dest = target_dir / skill_base / relative_path
|
|
507
|
+
write_bytes = content_bytes
|
|
508
|
+
|
|
509
|
+
# Security: ensure dest stays under target_dir
|
|
510
|
+
try:
|
|
511
|
+
dest.resolve().relative_to(target_dir.resolve())
|
|
512
|
+
except ValueError:
|
|
513
|
+
continue # path traversal - skip silently
|
|
514
|
+
|
|
515
|
+
existed = dest.exists()
|
|
516
|
+
if existed and not force:
|
|
517
|
+
result.skipped_files.append(dest)
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
521
|
+
dest.write_bytes(write_bytes)
|
|
522
|
+
|
|
523
|
+
if existed:
|
|
524
|
+
result.overwritten_files.append(dest)
|
|
525
|
+
else:
|
|
526
|
+
result.created_files.append(dest)
|
|
527
|
+
|
|
528
|
+
# Write provenance file
|
|
529
|
+
if platform != "claude":
|
|
530
|
+
provenance_dest_rel = config["file_pattern"].format(
|
|
531
|
+
skill_name=skill_name
|
|
532
|
+
)
|
|
533
|
+
provenance_dir = (
|
|
534
|
+
target_dir / str(PurePosixPath(provenance_dest_rel).parent)
|
|
535
|
+
)
|
|
536
|
+
provenance = {
|
|
537
|
+
"source": f"github:{ref.owner}/{ref.repo}",
|
|
538
|
+
"ref": ref.ref,
|
|
539
|
+
"skill_name": skill_name,
|
|
540
|
+
"description": metadata.get("description", ""),
|
|
541
|
+
"files": sorted(skill_files.keys()),
|
|
542
|
+
}
|
|
543
|
+
prov_path = provenance_dir / _PROVENANCE_FILE
|
|
544
|
+
prov_path.parent.mkdir(parents=True, exist_ok=True)
|
|
545
|
+
prov_path.write_text(
|
|
546
|
+
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
|
547
|
+
)
|
|
548
|
+
if prov_path not in result.created_files:
|
|
549
|
+
result.created_files.append(prov_path)
|
|
550
|
+
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@dataclass
|
|
555
|
+
class RegistrationResult:
|
|
556
|
+
"""Result of registering skills in coding agent instruction files.
|
|
557
|
+
|
|
558
|
+
Attributes:
|
|
559
|
+
registered_files: Instruction files that were created or updated.
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
registered_files: List[Path] = field(default_factory=list)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _register_copilot(resolved: Path) -> Path | None:
|
|
566
|
+
"""Register skills in `.github/copilot-instructions.md`.
|
|
567
|
+
|
|
568
|
+
- File absent → create with just the AgentOps block.
|
|
569
|
+
- File exists, no marker → append block at end.
|
|
570
|
+
- File exists, has marker → replace existing block (idempotent).
|
|
571
|
+
"""
|
|
572
|
+
dest = resolved / ".github" / "copilot-instructions.md"
|
|
573
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
|
|
575
|
+
if not dest.exists():
|
|
576
|
+
dest.write_text(_COPILOT_BLOCK + "\n", encoding="utf-8")
|
|
577
|
+
return dest
|
|
578
|
+
|
|
579
|
+
content = dest.read_text(encoding="utf-8")
|
|
580
|
+
|
|
581
|
+
if _COPILOT_MARKER_START in content:
|
|
582
|
+
# Replace existing block
|
|
583
|
+
pattern = re.compile(
|
|
584
|
+
re.escape(_COPILOT_MARKER_START) + r".*?" + re.escape(_COPILOT_MARKER_END),
|
|
585
|
+
re.DOTALL,
|
|
586
|
+
)
|
|
587
|
+
new_content = pattern.sub(_COPILOT_BLOCK, content)
|
|
588
|
+
if new_content != content:
|
|
589
|
+
dest.write_text(new_content, encoding="utf-8")
|
|
590
|
+
return dest
|
|
591
|
+
|
|
592
|
+
# Append to end
|
|
593
|
+
separator = "\n" if content.endswith("\n") else "\n\n"
|
|
594
|
+
dest.write_text(content + separator + _COPILOT_BLOCK + "\n", encoding="utf-8")
|
|
595
|
+
return dest
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _register_cursor(resolved: Path) -> Path | None:
|
|
599
|
+
"""Register skills in `.cursor/rules/agentops.mdc`.
|
|
600
|
+
|
|
601
|
+
Always overwrites - this is a fully managed file.
|
|
602
|
+
"""
|
|
603
|
+
dest = resolved / ".cursor" / "rules" / "agentops.mdc"
|
|
604
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
605
|
+
dest.write_text(_CURSOR_MDC, encoding="utf-8")
|
|
606
|
+
return dest
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# Map platform names to their registration functions.
|
|
610
|
+
_PLATFORM_REGISTRARS: Dict[str, object] = {
|
|
611
|
+
"copilot": _register_copilot,
|
|
612
|
+
"cursor": _register_cursor,
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def register_skills(
|
|
617
|
+
directory: Path,
|
|
618
|
+
platforms: list[str],
|
|
619
|
+
) -> RegistrationResult:
|
|
620
|
+
"""Register installed skills in coding agent instruction files.
|
|
621
|
+
|
|
622
|
+
For each detected platform, writes or updates the appropriate
|
|
623
|
+
instruction file so the AI assistant discovers the skill files.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
directory: Root directory of the consumer repository.
|
|
627
|
+
platforms: List of platform identifiers (e.g. ``["copilot"]``).
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
RegistrationResult with paths of instruction files that were updated.
|
|
631
|
+
"""
|
|
632
|
+
result = RegistrationResult()
|
|
633
|
+
resolved = directory.resolve()
|
|
634
|
+
|
|
635
|
+
for platform in platforms:
|
|
636
|
+
registrar = _PLATFORM_REGISTRARS.get(platform)
|
|
637
|
+
if registrar is None:
|
|
638
|
+
continue
|
|
639
|
+
path = registrar(resolved) # type: ignore[operator]
|
|
640
|
+
if path is not None:
|
|
641
|
+
result.registered_files.append(path)
|
|
642
|
+
|
|
643
|
+
return result
|