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.
- agentskill_kit/__init__.py +5 -0
- agentskill_kit/__main__.py +6 -0
- agentskill_kit/cli.py +795 -0
- agentskill_kit/config.py +104 -0
- agentskill_kit/evalrun.py +95 -0
- agentskill_kit/export.py +188 -0
- agentskill_kit/forge.py +346 -0
- agentskill_kit/i18n.py +107 -0
- agentskill_kit/install.py +212 -0
- agentskill_kit/lint.py +201 -0
- agentskill_kit/mine.py +291 -0
- agentskill_kit/mockmodel.py +124 -0
- agentskill_kit/providers.py +344 -0
- agentskill_kit/publish.py +96 -0
- agentskill_kit/registry.py +175 -0
- agentskill_kit/runner.py +214 -0
- agentskill_kit/sandbox.py +118 -0
- agentskill_kit/scaffold.py +90 -0
- agentskill_kit/scan.py +303 -0
- agentskill_kit/scoring.py +93 -0
- agentskill_kit/server.py +69 -0
- agentskill_kit/site.py +24 -0
- agentskill_kit/skillfile.py +378 -0
- agentskill_kit/templates/site.html +275 -0
- agentskill_kit/ui.py +111 -0
- agentskill_toolkit-0.1.0.dist-info/METADATA +314 -0
- agentskill_toolkit-0.1.0.dist-info/RECORD +31 -0
- agentskill_toolkit-0.1.0.dist-info/WHEEL +5 -0
- agentskill_toolkit-0.1.0.dist-info/entry_points.txt +3 -0
- agentskill_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentskill_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|