agentskill-toolkit 0.1.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.
@@ -0,0 +1,344 @@
1
+ """LLM access through plain HTTP. No SDKs, no extra dependencies.
2
+
3
+ Free options come first: Ollama (local), Groq, Gemini and OpenRouter all offer
4
+ a free tier or free models. Paid keys for OpenAI or Anthropic are optional and
5
+ work the same way. Anything that speaks the OpenAI chat completions protocol
6
+ can be used through the ``custom`` provider.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ from .config import get_setting
21
+
22
+ USER_AGENT = "agentskill-kit (+https://github.com/Lethe044/agentskill-kit)"
23
+ RETRY_STATUS = {408, 409, 425, 429, 500, 502, 503, 504}
24
+
25
+
26
+ class ProviderError(Exception):
27
+ """Raised for configuration problems and failed API calls."""
28
+
29
+
30
+ @dataclass
31
+ class ProviderSpec:
32
+ name: str
33
+ label: str
34
+ base_url: str
35
+ key_env: str = ""
36
+ default_model: str = ""
37
+ free: bool = True
38
+ kind: str = "openai" # openai | anthropic | mock
39
+ note: str = ""
40
+ extra_key_envs: tuple = ()
41
+ signup: str = ""
42
+
43
+
44
+ PROVIDERS: Dict[str, ProviderSpec] = {
45
+ "ollama": ProviderSpec("ollama", "Ollama (local)", "http://localhost:11434/v1", "", "",
46
+ True, note="Runs on your machine. No key, no limits.",
47
+ signup="https://ollama.com/download"),
48
+ "lmstudio": ProviderSpec("lmstudio", "LM Studio (local)", "http://localhost:1234/v1", "", "",
49
+ True, note="Start the local server in LM Studio first.",
50
+ signup="https://lmstudio.ai"),
51
+ "groq": ProviderSpec("groq", "Groq (free tier)", "https://api.groq.com/openai/v1", "GROQ_API_KEY",
52
+ "llama-3.3-70b-versatile", True, note="Fast hosted open models, generous free tier.",
53
+ signup="https://console.groq.com/keys"),
54
+ "gemini": ProviderSpec("gemini", "Google Gemini (free tier)",
55
+ "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY",
56
+ "gemini-2.5-flash", True, extra_key_envs=("GOOGLE_API_KEY",),
57
+ note="Free tier through Google AI Studio.", signup="https://aistudio.google.com/apikey"),
58
+ "openrouter": ProviderSpec("openrouter", "OpenRouter (free models)", "https://openrouter.ai/api/v1",
59
+ "OPENROUTER_API_KEY", "meta-llama/llama-3.3-70b-instruct:free", True,
60
+ note="Models ending in :free cost nothing.", signup="https://openrouter.ai/keys"),
61
+ "openai": ProviderSpec("openai", "OpenAI (paid, optional)", "https://api.openai.com/v1", "OPENAI_API_KEY",
62
+ "gpt-4o-mini", False, signup="https://platform.openai.com/api-keys"),
63
+ "anthropic": ProviderSpec("anthropic", "Anthropic (paid, optional)", "https://api.anthropic.com/v1",
64
+ "ANTHROPIC_API_KEY", "claude-haiku-5-5", False, kind="anthropic",
65
+ signup="https://console.anthropic.com/settings/keys"),
66
+ "custom": ProviderSpec("custom", "Custom OpenAI-compatible endpoint", "", "ASKIT_API_KEY", "", True,
67
+ note="Set ASKIT_BASE_URL and ASKIT_MODEL (and ASKIT_API_KEY if needed)."),
68
+ "mock": ProviderSpec("mock", "Offline demo model", "", "", "mock-forge", True, kind="mock",
69
+ note="Returns a built-in example skill. For demos and tests, no network."),
70
+ }
71
+
72
+ AUTO_ORDER = ["groq", "gemini", "openrouter", "anthropic", "openai"]
73
+ OLLAMA_PREFERENCE = ["qwen2.5-coder", "qwen3", "llama3.3", "llama3.1", "llama3.2", "mistral", "gemma", "phi"]
74
+
75
+
76
+ @dataclass
77
+ class ChatResult:
78
+ content: str
79
+ model: str = ""
80
+ prompt_tokens: int = 0
81
+ completion_tokens: int = 0
82
+
83
+
84
+ def _http_json(url: str, payload: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None,
85
+ timeout: float = 120.0, method: Optional[str] = None) -> Any:
86
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
87
+ hdrs = {"Content-Type": "application/json", "User-Agent": USER_AGENT, "Accept": "application/json"}
88
+ hdrs.update(headers or {})
89
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method or ("POST" if data else "GET"))
90
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
91
+ return json.loads(resp.read().decode("utf-8", errors="replace"))
92
+
93
+
94
+ def _error_message(exc: urllib.error.HTTPError) -> str:
95
+ try:
96
+ body = exc.read().decode("utf-8", errors="replace")
97
+ data = json.loads(body)
98
+ if isinstance(data, list) and data:
99
+ data = data[0]
100
+ err = data.get("error", data) if isinstance(data, dict) else data
101
+ if isinstance(err, dict):
102
+ return str(err.get("message") or err)[:300]
103
+ return str(err)[:300]
104
+ except Exception:
105
+ return exc.reason if hasattr(exc, "reason") else str(exc)
106
+
107
+
108
+ def strip_reasoning(text: str) -> str:
109
+ """Remove <think> blocks that reasoning models put in the answer."""
110
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.S | re.I)
111
+ text = re.sub(r"^.*?</think>", "", text, flags=re.S) if "</think>" in text and "<think>" not in text else text
112
+ return text.strip()
113
+
114
+
115
+ class LLM:
116
+ """A configured model endpoint."""
117
+
118
+ def __init__(self, spec: ProviderSpec, model: str = "", api_key: str = "", base_url: str = "",
119
+ timeout: float = 120.0, max_retries: int = 4, scripted: Optional[List[str]] = None):
120
+ self.spec = spec
121
+ self.model = model or spec.default_model
122
+ self.api_key = api_key
123
+ self.base_url = (base_url or spec.base_url).rstrip("/")
124
+ self.timeout = timeout
125
+ self.max_retries = max_retries
126
+ self._scripted = list(scripted) if scripted is not None else None
127
+ self.calls = 0
128
+
129
+ @property
130
+ def name(self) -> str:
131
+ return self.spec.name
132
+
133
+ @property
134
+ def is_free(self) -> bool:
135
+ return self.spec.free
136
+
137
+ def describe(self) -> str:
138
+ return "%s / %s" % (self.spec.name, self.model or "?")
139
+
140
+ def _headers(self) -> Dict[str, str]:
141
+ h: Dict[str, str] = {}
142
+ if self.spec.kind == "anthropic":
143
+ h["x-api-key"] = self.api_key
144
+ h["anthropic-version"] = "2023-06-01"
145
+ elif self.api_key:
146
+ h["Authorization"] = "Bearer " + self.api_key
147
+ if self.spec.name == "openrouter":
148
+ h["HTTP-Referer"] = "https://github.com/Lethe044/agentskill-kit"
149
+ h["X-Title"] = "agentskill-kit"
150
+ return h
151
+
152
+ def chat(self, messages: List[Dict[str, str]], temperature: float = 0.2, max_tokens: int = 4096) -> ChatResult:
153
+ self.calls += 1
154
+ if self.spec.kind == "mock":
155
+ from .mockmodel import mock_reply
156
+ if self._scripted is not None:
157
+ if not self._scripted:
158
+ raise ProviderError("mock provider ran out of scripted replies")
159
+ return ChatResult(self._scripted.pop(0), model="mock")
160
+ return ChatResult(mock_reply(messages), model="mock")
161
+ if not self.base_url:
162
+ raise ProviderError("no base URL for provider %r (set ASKIT_BASE_URL)" % self.name)
163
+ if not self.model:
164
+ raise ProviderError("no model chosen for %s. Pass --model, or run: askit providers --models" % self.name)
165
+ if self.spec.kind == "anthropic":
166
+ return self._chat_anthropic(messages, temperature, max_tokens)
167
+ payload = {"model": self.model, "messages": messages, "temperature": temperature,
168
+ "max_tokens": max_tokens}
169
+ data = self._request(self.base_url + "/chat/completions", payload)
170
+ try:
171
+ msg = data["choices"][0]["message"]
172
+ content = msg.get("content") or ""
173
+ if isinstance(content, list):
174
+ content = "".join(p.get("text", "") for p in content if isinstance(p, dict))
175
+ except (KeyError, IndexError, TypeError, AttributeError):
176
+ raise ProviderError("unexpected response from %s: %s" % (self.name, str(data)[:200]))
177
+ usage = data.get("usage") or {}
178
+ return ChatResult(strip_reasoning(content), data.get("model", self.model),
179
+ int(usage.get("prompt_tokens", 0) or 0), int(usage.get("completion_tokens", 0) or 0))
180
+
181
+ def _chat_anthropic(self, messages: List[Dict[str, str]], temperature: float, max_tokens: int) -> ChatResult:
182
+ system = "\n\n".join(m["content"] for m in messages if m["role"] == "system")
183
+ convo = [m for m in messages if m["role"] != "system"]
184
+ payload: Dict[str, Any] = {"model": self.model, "max_tokens": max_tokens,
185
+ "temperature": temperature, "messages": convo}
186
+ if system:
187
+ payload["system"] = system
188
+ data = self._request(self.base_url + "/messages", payload)
189
+ try:
190
+ content = "".join(b.get("text", "") for b in data["content"] if b.get("type") == "text")
191
+ except (KeyError, TypeError):
192
+ raise ProviderError("unexpected response from anthropic: %s" % str(data)[:200])
193
+ usage = data.get("usage") or {}
194
+ return ChatResult(content.strip(), data.get("model", self.model),
195
+ int(usage.get("input_tokens", 0)), int(usage.get("output_tokens", 0)))
196
+
197
+ def _request(self, url: str, payload: Dict[str, Any]) -> Any:
198
+ last = ""
199
+ for attempt in range(self.max_retries + 1):
200
+ try:
201
+ return _http_json(url, payload, self._headers(), self.timeout)
202
+ except urllib.error.HTTPError as exc:
203
+ msg = _error_message(exc)
204
+ if exc.code in RETRY_STATUS and attempt < self.max_retries:
205
+ wait = _retry_after(exc, attempt)
206
+ time.sleep(wait)
207
+ last = "HTTP %d: %s" % (exc.code, msg)
208
+ continue
209
+ raise ProviderError(self._explain(exc.code, msg))
210
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as exc:
211
+ reason = getattr(exc, "reason", exc)
212
+ last = str(reason)
213
+ if attempt < self.max_retries:
214
+ time.sleep(min(2 ** attempt, 8))
215
+ continue
216
+ hint = ""
217
+ if self.spec.name in ("ollama", "lmstudio"):
218
+ hint = " Is the local server running?"
219
+ raise ProviderError("could not reach %s: %s.%s" % (self.name, last, hint))
220
+ raise ProviderError("request failed after retries: %s" % last)
221
+
222
+ def _explain(self, code: int, msg: str) -> str:
223
+ base = "%s returned HTTP %d: %s" % (self.name, code, msg)
224
+ if code in (401, 403):
225
+ env = self.spec.key_env or "ASKIT_API_KEY"
226
+ return base + ". Check the API key in %s." % env
227
+ if code == 404:
228
+ return base + ". The model name may be wrong. Run: askit providers --models --provider %s" % self.name
229
+ if code == 429:
230
+ return base + ". Rate limit reached. Wait a minute or switch provider."
231
+ return base
232
+
233
+ def list_models(self) -> List[str]:
234
+ if self.spec.kind == "mock":
235
+ return ["mock-forge"]
236
+ data = _http_json(self.base_url + "/models", None, self._headers(), 30)
237
+ items = data.get("data") or data.get("models") or []
238
+ names = []
239
+ for it in items:
240
+ if isinstance(it, dict):
241
+ names.append(str(it.get("id") or it.get("name")))
242
+ else:
243
+ names.append(str(it))
244
+ return sorted(n.replace("models/", "") for n in names if n)
245
+
246
+
247
+ def _retry_after(exc: urllib.error.HTTPError, attempt: int) -> float:
248
+ try:
249
+ ra = exc.headers.get("Retry-After") if exc.headers else None
250
+ if ra:
251
+ return min(float(ra), 60.0)
252
+ except (ValueError, TypeError):
253
+ pass
254
+ return min(1.5 * (2 ** attempt), 30.0)
255
+
256
+
257
+ def ollama_models(base: str = "http://localhost:11434", timeout: float = 1.0) -> List[str]:
258
+ try:
259
+ data = _http_json(base.rstrip("/") + "/api/tags", None, None, timeout)
260
+ return [m.get("name", "") for m in data.get("models", []) if m.get("name")]
261
+ except Exception:
262
+ return []
263
+
264
+
265
+ def _pick_ollama_model(installed: List[str]) -> str:
266
+ for pref in OLLAMA_PREFERENCE:
267
+ for name in installed:
268
+ if name.lower().startswith(pref):
269
+ return name
270
+ return installed[0] if installed else ""
271
+
272
+
273
+ def _env_key(spec: ProviderSpec) -> str:
274
+ for env in (spec.key_env,) + tuple(spec.extra_key_envs):
275
+ if env and os.environ.get(env):
276
+ return os.environ[env]
277
+ return ""
278
+
279
+
280
+ def detect_available() -> List[str]:
281
+ """Providers that look usable right now: local first, then free keys, then paid keys."""
282
+ keyed = [n for n in AUTO_ORDER if _env_key(PROVIDERS[n])]
283
+ keyed.sort(key=lambda n: (not PROVIDERS[n].free, AUTO_ORDER.index(n)))
284
+ return (["ollama"] if ollama_models() else []) + keyed
285
+
286
+
287
+ def resolve_provider(name: Optional[str] = None, model: Optional[str] = None,
288
+ base_url: Optional[str] = None, api_key: Optional[str] = None) -> LLM:
289
+ chosen = (name or get_setting("provider") or "").lower()
290
+ if not chosen:
291
+ avail = detect_available()
292
+ if not avail:
293
+ raise ProviderError(
294
+ "No LLM provider found. Free options:\n"
295
+ " - Ollama (local): install from https://ollama.com, then run: ollama pull llama3.1\n"
296
+ " - Groq (free tier): set GROQ_API_KEY (https://console.groq.com/keys)\n"
297
+ " - Gemini (free tier): set GEMINI_API_KEY (https://aistudio.google.com/apikey)\n"
298
+ " - OpenRouter (free models): set OPENROUTER_API_KEY\n"
299
+ "Or try the offline demo with --provider mock.")
300
+ chosen = avail[0]
301
+ if chosen not in PROVIDERS:
302
+ raise ProviderError("unknown provider %r. Choose one of: %s" % (chosen, ", ".join(PROVIDERS)))
303
+ spec = PROVIDERS[chosen]
304
+ chosen_model = model or get_setting("model") or ""
305
+ url = base_url or (get_setting("base_url") if chosen == (get_setting("provider") or "") else "") or ""
306
+ key = api_key or _env_key(spec)
307
+ if chosen == "custom":
308
+ url = url or os.environ.get("ASKIT_BASE_URL", "")
309
+ chosen_model = chosen_model or os.environ.get("ASKIT_MODEL", "")
310
+ if not url:
311
+ raise ProviderError("custom provider needs ASKIT_BASE_URL (and usually ASKIT_MODEL)")
312
+ if chosen == "ollama" and not chosen_model:
313
+ chosen_model = _pick_ollama_model(ollama_models())
314
+ if not chosen_model:
315
+ raise ProviderError("Ollama is not running or has no models. Try: ollama pull llama3.1")
316
+ if spec.key_env and not key and chosen not in ("custom",):
317
+ raise ProviderError("%s needs an API key. Set %s%s" % (
318
+ spec.label, spec.key_env, (" (get one at %s)" % spec.signup) if spec.signup else ""))
319
+ return LLM(spec, chosen_model, key, url)
320
+
321
+
322
+ def provider_status() -> List[Dict[str, Any]]:
323
+ rows = []
324
+ for name, spec in PROVIDERS.items():
325
+ if spec.kind == "mock":
326
+ state = "built in"
327
+ elif name in ("ollama", "lmstudio"):
328
+ base = spec.base_url.replace("/v1", "")
329
+ if name == "ollama":
330
+ models = ollama_models(base)
331
+ state = "running (%d models)" % len(models) if models else "not detected"
332
+ else:
333
+ try:
334
+ _http_json(spec.base_url + "/models", None, None, 1.0)
335
+ state = "running"
336
+ except Exception:
337
+ state = "not detected"
338
+ elif name == "custom":
339
+ state = "configured" if os.environ.get("ASKIT_BASE_URL") else "not set"
340
+ else:
341
+ state = "key found" if _env_key(spec) else "no key"
342
+ rows.append({"name": name, "label": spec.label, "free": spec.free, "state": state,
343
+ "model": spec.default_model, "key_env": spec.key_env, "signup": spec.signup})
344
+ return rows
@@ -0,0 +1,96 @@
1
+ """Quality gates, packaging and contribution helpers for sharing skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import zipfile
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ from . import registry
13
+ from .scoring import Quality, evaluate_skill
14
+ from .skillfile import iter_skill_files, load_skill
15
+
16
+
17
+ class PublishError(Exception):
18
+ pass
19
+
20
+
21
+ def check_gate(quality: Quality, threshold: float) -> List[str]:
22
+ """Return the reasons a skill may not be published (empty when it can)."""
23
+ reasons: List[str] = []
24
+ if quality.lint and quality.lint.errors:
25
+ reasons.append("%d lint error(s)" % len(quality.lint.errors))
26
+ if quality.scan and quality.scan.count("high"):
27
+ reasons.append("%d high severity security finding(s)" % quality.scan.count("high"))
28
+ if quality.tests and quality.tests.refused:
29
+ reasons.append(quality.tests.refused)
30
+ elif quality.tests and quality.tests.has_tests and quality.tests.rate < 1.0:
31
+ reasons.append("tests failing (%d/%d)" % (quality.tests.passed, quality.tests.total))
32
+ if quality.score < threshold:
33
+ reasons.append("score %.2f is below %.2f" % (quality.score, threshold))
34
+ return reasons
35
+
36
+
37
+ def pack(skill_dir: Any, out_dir: Any = ".") -> Path:
38
+ """Create <name>-<version>.zip with a checksum manifest."""
39
+ skill = load_skill(skill_dir)
40
+ files = iter_skill_files(skill.path)
41
+ manifest = {"name": skill.name, "version": skill.version or "0.0.0",
42
+ "files": [{"path": f.as_posix(), "sha256": registry.sha256_file(skill.path / f)} for f in files]}
43
+ out = Path(out_dir)
44
+ out.mkdir(parents=True, exist_ok=True)
45
+ archive = out / ("%s-%s.zip" % (skill.name, skill.version or "0.0.0"))
46
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
47
+ for f in files:
48
+ zf.write(skill.path / f, "%s/%s" % (skill.name, f.as_posix()))
49
+ zf.writestr("%s/MANIFEST.json" % skill.name, json.dumps(manifest, indent=2) + "\n")
50
+ return archive
51
+
52
+
53
+ def publish_to_registry(skill_dir: Any, registry_root: Any, threshold: float = 0.8, force: bool = False,
54
+ dry_run: bool = False) -> Dict[str, Any]:
55
+ """Copy a skill into a registry checkout (skills/<name>) and refresh its index."""
56
+ skill = load_skill(skill_dir)
57
+ quality = evaluate_skill(skill.path)
58
+ reasons = check_gate(quality, threshold)
59
+ if reasons and not force:
60
+ raise PublishError("; ".join(reasons))
61
+ root = Path(registry_root).expanduser()
62
+ skills_dir = root / "skills"
63
+ if not skills_dir.is_dir():
64
+ raise PublishError("%s does not look like a registry checkout (no skills/ folder)" % root)
65
+ dest = skills_dir / skill.name
66
+ steps = ["copy %s -> %s" % (skill.path, dest), "rebuild %s" % (root / "registry" / "index.json")]
67
+ if not dry_run:
68
+ if dest.exists():
69
+ shutil.rmtree(dest)
70
+ shutil.copytree(skill.path, dest, ignore=shutil.ignore_patterns("__pycache__", ".git", "*.pyc"))
71
+ index = registry.build_index([skills_dir], relative_to=root)
72
+ registry.write_index(index, root / "registry" / "index.json")
73
+ return {"name": skill.name, "quality": quality, "steps": steps, "dest": dest, "forced": bool(reasons)}
74
+
75
+
76
+ def pr_commands(name: str, registry_root: Any = ".") -> List[str]:
77
+ """Git and GitHub CLI commands that open a pull request for a skill."""
78
+ return [
79
+ "git checkout -b add-skill-%s" % name,
80
+ "git add skills/%s registry/index.json" % name,
81
+ 'git commit -m "Add %s skill"' % name,
82
+ "git push -u origin add-skill-%s" % name,
83
+ 'gh pr create --title "Add %s skill" --body "New skill: %s. Lint, scan and tests pass."' % (name, name),
84
+ ]
85
+
86
+
87
+ def run_pr(name: str, registry_root: Any) -> Tuple[bool, str]:
88
+ """Run the commands from pr_commands inside the registry checkout. Needs git and gh."""
89
+ root = str(Path(registry_root).expanduser())
90
+ if not shutil.which("git") or not shutil.which("gh"):
91
+ return False, "git and the GitHub CLI (gh) must be installed"
92
+ for cmd in pr_commands(name):
93
+ proc = subprocess.run(cmd, shell=True, cwd=root, capture_output=True, text=True)
94
+ if proc.returncode != 0:
95
+ return False, "%s\n%s" % (cmd, (proc.stderr or proc.stdout).strip())
96
+ return True, "pull request created"
@@ -0,0 +1,175 @@
1
+ """Build, search and publish a static skill registry.
2
+
3
+ A registry is a folder of skills plus a generated ``index.json``. Hosting it
4
+ needs nothing but static files, so GitHub (raw files and Pages) is enough.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import re
13
+ import urllib.request
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Optional
16
+
17
+ from . import __version__
18
+ from .config import RAW_BASE
19
+ from .runner import has_tests
20
+ from .scoring import evaluate_skill
21
+ from .skillfile import SkillError, discover_skills, iter_skill_files, load_skill
22
+
23
+ SCHEMA = 1
24
+ USER_AGENT = "agentskill-kit/%s" % __version__
25
+
26
+
27
+ def sha256_file(path: Path) -> str:
28
+ h = hashlib.sha256()
29
+ with open(path, "rb") as fh:
30
+ for chunk in iter(lambda: fh.read(65536), b""):
31
+ h.update(chunk)
32
+ return h.hexdigest()
33
+
34
+
35
+ def skill_entry(skill_dir: Path, root: Path, include_body: bool = True, run_tests: bool = True) -> Dict[str, Any]:
36
+ """Describe one skill for the index (lint, scan and, optionally, tests are run)."""
37
+ skill = load_skill(skill_dir)
38
+ q = evaluate_skill(skill_dir, run_tests=run_tests)
39
+ lint, scan = q.lint, q.scan
40
+ assert lint is not None and scan is not None
41
+ try:
42
+ rel = skill_dir.resolve().relative_to(root.resolve()).as_posix()
43
+ except ValueError:
44
+ rel = skill_dir.name
45
+ files = [{"path": f.as_posix(), "sha256": sha256_file(skill_dir / f), "size": (skill_dir / f).stat().st_size}
46
+ for f in iter_skill_files(skill_dir)]
47
+ entry: Dict[str, Any] = {
48
+ "name": skill.name,
49
+ "description": skill.description,
50
+ "version": skill.version or "0.0.0",
51
+ "tags": skill.tags,
52
+ "author": skill.author,
53
+ "license": skill.license,
54
+ "path": rel,
55
+ "files": files,
56
+ "score": round(q.score, 3),
57
+ "grade": q.grade,
58
+ "safety": scan.safety,
59
+ "lint_errors": len(lint.errors),
60
+ "lint_warnings": len(lint.warnings),
61
+ "scan": {"high": scan.count("high"), "medium": scan.count("medium"), "low": scan.count("low")},
62
+ "tested": has_tests(skill_dir),
63
+ "tests_passed": q.tests.passed if q.tests else 0,
64
+ "tests_total": q.tests.total if q.tests else 0,
65
+ "has_scripts": any(f["path"].startswith("scripts/") for f in files),
66
+ }
67
+ if include_body:
68
+ entry["body"] = skill.body.strip()
69
+ return entry
70
+
71
+
72
+ def build_index(roots: Iterable[Any], base_url: str = RAW_BASE, name: str = "agentskill-kit registry",
73
+ include_body: bool = True, relative_to: Optional[Path] = None,
74
+ run_tests: bool = True) -> Dict[str, Any]:
75
+ """Create the registry index for every skill under the given roots."""
76
+ root_paths = [Path(r) for r in roots]
77
+ base = Path(relative_to) if relative_to else (root_paths[0].parent if root_paths else Path.cwd())
78
+ entries: List[Dict[str, Any]] = []
79
+ seen = set()
80
+ for r in root_paths:
81
+ for d in discover_skills(r):
82
+ try:
83
+ e = skill_entry(d, base, include_body, run_tests)
84
+ except SkillError:
85
+ continue
86
+ if e["name"] in seen:
87
+ continue
88
+ seen.add(e["name"])
89
+ entries.append(e)
90
+ entries.sort(key=lambda e: e["name"])
91
+ return {"schema": SCHEMA, "name": name, "generator": "agentskill-kit %s" % __version__,
92
+ "base_url": base_url, "count": len(entries), "skills": entries}
93
+
94
+
95
+ def write_index(index: Dict[str, Any], dest: Path) -> None:
96
+ dest.parent.mkdir(parents=True, exist_ok=True)
97
+ dest.write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
98
+
99
+
100
+ def index_is_current(index: Dict[str, Any], dest: Path) -> bool:
101
+ if not dest.exists():
102
+ return False
103
+ try:
104
+ current = json.loads(dest.read_text(encoding="utf-8"))
105
+ except ValueError:
106
+ return False
107
+ return json.dumps(current, sort_keys=True) == json.dumps(index, sort_keys=True)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Loading and search
112
+ # ---------------------------------------------------------------------------
113
+
114
+ def fetch_bytes(url: str, timeout: float = 30.0) -> bytes:
115
+ if re.match(r"^[a-z][a-z0-9+.-]*://", url, re.I):
116
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
117
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
118
+ return resp.read()
119
+ return Path(url).expanduser().read_bytes()
120
+
121
+
122
+ def load_index(source: str) -> Dict[str, Any]:
123
+ try:
124
+ data = json.loads(fetch_bytes(source).decode("utf-8"))
125
+ except Exception as exc:
126
+ raise SkillError("could not load registry %s: %s" % (source, exc))
127
+ if not isinstance(data, dict) or "skills" not in data:
128
+ raise SkillError("%s is not a valid registry index" % source)
129
+ return data
130
+
131
+
132
+ def _tokens(text: str) -> List[str]:
133
+ return re.findall(r"[a-z0-9]+", text.lower())
134
+
135
+
136
+ def search(index: Dict[str, Any], query: str, tag: str = "", limit: int = 20) -> List[Dict[str, Any]]:
137
+ """Rank skills with a small BM25-style score over name, tags and description."""
138
+ skills = index.get("skills", [])
139
+ if tag:
140
+ skills = [s for s in skills if tag.lower() in [t.lower() for t in s.get("tags", [])]]
141
+ q = _tokens(query)
142
+ if not q:
143
+ return sorted(skills, key=lambda s: -s.get("score", 0))[:limit]
144
+ docs = []
145
+ for s in skills:
146
+ fields = {"name": _tokens(s["name"].replace("-", " ")), "tags": _tokens(" ".join(s.get("tags", []))),
147
+ "desc": _tokens(s.get("description", ""))}
148
+ docs.append((s, fields))
149
+ n = max(1, len(docs))
150
+ df: Dict[str, int] = {}
151
+ for _, f in docs:
152
+ for term in set(f["name"] + f["tags"] + f["desc"]):
153
+ df[term] = df.get(term, 0) + 1
154
+ scored = []
155
+ for s, f in docs:
156
+ total = 0.0
157
+ for term in q:
158
+ idf = math.log(1 + (n - df.get(term, 0) + 0.5) / (df.get(term, 0) + 0.5))
159
+ tf = 3.0 * (term in f["name"]) + 2.0 * (term in f["tags"]) + min(2, f["desc"].count(term)) * 0.8
160
+ if not tf:
161
+ # prefix match so "summar" finds "summarizer"
162
+ tf = 1.5 * any(w.startswith(term) for w in f["name"] + f["tags"]) + \
163
+ 0.5 * any(w.startswith(term) for w in f["desc"])
164
+ total += idf * tf
165
+ if total > 0:
166
+ scored.append((total + s.get("score", 0) * 0.3, s))
167
+ scored.sort(key=lambda x: -x[0])
168
+ return [s for _, s in scored[:limit]]
169
+
170
+
171
+ def find(index: Dict[str, Any], name: str) -> Optional[Dict[str, Any]]:
172
+ for s in index.get("skills", []):
173
+ if s["name"] == name:
174
+ return s
175
+ return None