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.
Files changed (142) hide show
  1. agentops/__init__.py +10 -0
  2. agentops/__main__.py +6 -0
  3. agentops/agent/__init__.py +12 -0
  4. agentops/agent/_legacy_ids.py +92 -0
  5. agentops/agent/analyzer.py +207 -0
  6. agentops/agent/checks/__init__.py +1 -0
  7. agentops/agent/checks/catalog.py +880 -0
  8. agentops/agent/checks/errors.py +279 -0
  9. agentops/agent/checks/foundry_config.py +75 -0
  10. agentops/agent/checks/latency.py +84 -0
  11. agentops/agent/checks/opex.py +157 -0
  12. agentops/agent/checks/opex_workspace.py +874 -0
  13. agentops/agent/checks/posture.py +36 -0
  14. agentops/agent/checks/posture_rules/__init__.py +53 -0
  15. agentops/agent/checks/posture_rules/content_filter.py +59 -0
  16. agentops/agent/checks/posture_rules/diagnostics.py +74 -0
  17. agentops/agent/checks/posture_rules/local_auth.py +55 -0
  18. agentops/agent/checks/posture_rules/managed_identity.py +59 -0
  19. agentops/agent/checks/posture_rules/network.py +68 -0
  20. agentops/agent/checks/regression.py +78 -0
  21. agentops/agent/checks/release_readiness.py +182 -0
  22. agentops/agent/checks/safety.py +247 -0
  23. agentops/agent/checks/spec_conformance.py +375 -0
  24. agentops/agent/cockpit.py +5159 -0
  25. agentops/agent/config.py +240 -0
  26. agentops/agent/findings.py +113 -0
  27. agentops/agent/history.py +142 -0
  28. agentops/agent/knowledge/__init__.py +182 -0
  29. agentops/agent/knowledge/waf-checklist.csv +39 -0
  30. agentops/agent/llm_assist/__init__.py +16 -0
  31. agentops/agent/llm_assist/_base.py +124 -0
  32. agentops/agent/llm_assist/_bundle_rule.py +154 -0
  33. agentops/agent/llm_assist/_client.py +347 -0
  34. agentops/agent/llm_assist/_dataset_rules.py +191 -0
  35. agentops/agent/llm_assist/_engine.py +106 -0
  36. agentops/agent/llm_assist/_prompt_rules.py +291 -0
  37. agentops/agent/llm_assist/_spec_rules.py +235 -0
  38. agentops/agent/production_telemetry.py +430 -0
  39. agentops/agent/report.py +207 -0
  40. agentops/agent/server/__init__.py +1 -0
  41. agentops/agent/server/app.py +84 -0
  42. agentops/agent/server/auth.py +94 -0
  43. agentops/agent/server/chat.py +44 -0
  44. agentops/agent/server/protocol.py +72 -0
  45. agentops/agent/sources/__init__.py +1 -0
  46. agentops/agent/sources/azure_monitor.py +523 -0
  47. agentops/agent/sources/azure_resources.py +602 -0
  48. agentops/agent/sources/foundry_control.py +174 -0
  49. agentops/agent/sources/results_history.py +494 -0
  50. agentops/agent/sources/spec_detectors/__init__.py +42 -0
  51. agentops/agent/sources/spec_detectors/_base.py +58 -0
  52. agentops/agent/sources/spec_detectors/agents_md.py +75 -0
  53. agentops/agent/sources/spec_detectors/spec_kit.py +172 -0
  54. agentops/agent/time_range.py +117 -0
  55. agentops/cli/__init__.py +1 -0
  56. agentops/cli/app.py +4823 -0
  57. agentops/core/__init__.py +1 -0
  58. agentops/core/agentops_config.py +592 -0
  59. agentops/core/config_loader.py +22 -0
  60. agentops/core/evaluators.py +480 -0
  61. agentops/core/release_evidence.py +56 -0
  62. agentops/core/results.py +117 -0
  63. agentops/mcp/__init__.py +10 -0
  64. agentops/mcp/server.py +232 -0
  65. agentops/pipeline/__init__.py +8 -0
  66. agentops/pipeline/cloud_results.py +189 -0
  67. agentops/pipeline/cloud_runner.py +901 -0
  68. agentops/pipeline/comparison.py +108 -0
  69. agentops/pipeline/diagnostics.py +51 -0
  70. agentops/pipeline/invocations.py +535 -0
  71. agentops/pipeline/official_eval.py +414 -0
  72. agentops/pipeline/orchestrator.py +775 -0
  73. agentops/pipeline/prompt_deploy.py +377 -0
  74. agentops/pipeline/publisher.py +121 -0
  75. agentops/pipeline/reporter.py +202 -0
  76. agentops/pipeline/runtime.py +409 -0
  77. agentops/pipeline/thresholds.py +84 -0
  78. agentops/services/__init__.py +1 -0
  79. agentops/services/cicd.py +720 -0
  80. agentops/services/eval_analysis.py +848 -0
  81. agentops/services/evidence_pack.py +757 -0
  82. agentops/services/initializer.py +86 -0
  83. agentops/services/preflight.py +470 -0
  84. agentops/services/setup_wizard.py +709 -0
  85. agentops/services/skills.py +643 -0
  86. agentops/services/trace_promotion.py +300 -0
  87. agentops/services/workflow_analysis.py +1129 -0
  88. agentops/templates/.gitignore +15 -0
  89. agentops/templates/__init__.py +1 -0
  90. agentops/templates/agent-server/Dockerfile +23 -0
  91. agentops/templates/agent-server/README.md +61 -0
  92. agentops/templates/agent-server/main.bicep +94 -0
  93. agentops/templates/agent.yaml +87 -0
  94. agentops/templates/agentops.yaml +58 -0
  95. agentops/templates/foundry.svg +71 -0
  96. agentops/templates/icon.png +0 -0
  97. agentops/templates/pipelines/azuredevops/agentops-deploy-dev-azd.yml +118 -0
  98. agentops/templates/pipelines/azuredevops/agentops-deploy-dev.yml +73 -0
  99. agentops/templates/pipelines/azuredevops/agentops-deploy-prod-azd.yml +141 -0
  100. agentops/templates/pipelines/azuredevops/agentops-deploy-prod.yml +94 -0
  101. agentops/templates/pipelines/azuredevops/agentops-deploy-prompt-agent.yml +167 -0
  102. agentops/templates/pipelines/azuredevops/agentops-deploy-qa-azd.yml +118 -0
  103. agentops/templates/pipelines/azuredevops/agentops-deploy-qa.yml +68 -0
  104. agentops/templates/pipelines/azuredevops/agentops-pr-prompt-agent.yml +210 -0
  105. agentops/templates/pipelines/azuredevops/agentops-pr.yml +155 -0
  106. agentops/templates/pipelines/azuredevops/agentops-watchdog.yml +106 -0
  107. agentops/templates/project.gitignore +36 -0
  108. agentops/templates/sample-traces.jsonl +3 -0
  109. agentops/templates/skills/agentops-agent/SKILL.md +137 -0
  110. agentops/templates/skills/agentops-config/SKILL.md +113 -0
  111. agentops/templates/skills/agentops-dataset/SKILL.md +84 -0
  112. agentops/templates/skills/agentops-eval/SKILL.md +189 -0
  113. agentops/templates/skills/agentops-report/SKILL.md +71 -0
  114. agentops/templates/skills/agentops-workflow/SKILL.md +471 -0
  115. agentops/templates/smoke.jsonl +3 -0
  116. agentops/templates/waf-checklist.README.md +84 -0
  117. agentops/templates/waf-checklist.csv +22 -0
  118. agentops/templates/workflows/agentops-deploy-dev-azd.yml +166 -0
  119. agentops/templates/workflows/agentops-deploy-dev.yml +187 -0
  120. agentops/templates/workflows/agentops-deploy-prod-azd.yml +183 -0
  121. agentops/templates/workflows/agentops-deploy-prod.yml +171 -0
  122. agentops/templates/workflows/agentops-deploy-prompt-agent.yml +197 -0
  123. agentops/templates/workflows/agentops-deploy-qa-azd.yml +156 -0
  124. agentops/templates/workflows/agentops-deploy-qa.yml +145 -0
  125. agentops/templates/workflows/agentops-pr-prompt-agent.yml +210 -0
  126. agentops/templates/workflows/agentops-pr.yml +148 -0
  127. agentops/templates/workflows/agentops-watchdog.yml +122 -0
  128. agentops/utils/__init__.py +1 -0
  129. agentops/utils/azd_env.py +435 -0
  130. agentops/utils/azure_endpoints.py +62 -0
  131. agentops/utils/colors.py +47 -0
  132. agentops/utils/dotenv_loader.py +105 -0
  133. agentops/utils/foundry_discovery.py +229 -0
  134. agentops/utils/logging.py +59 -0
  135. agentops/utils/telemetry.py +554 -0
  136. agentops/utils/yaml.py +36 -0
  137. agentops_accelerator-0.3.0.dist-info/METADATA +278 -0
  138. agentops_accelerator-0.3.0.dist-info/RECORD +142 -0
  139. agentops_accelerator-0.3.0.dist-info/WHEEL +5 -0
  140. agentops_accelerator-0.3.0.dist-info/entry_points.txt +2 -0
  141. agentops_accelerator-0.3.0.dist-info/licenses/LICENSE +21 -0
  142. 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