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,378 @@
1
+ """Reading and writing SKILL.md files.
2
+
3
+ A skill is a directory that contains a SKILL.md file. The file starts with a
4
+ small YAML frontmatter block (name, description and a few optional keys) and
5
+ continues with markdown instructions. To keep the package free of runtime
6
+ dependencies, this module ships a small YAML reader that covers the subset
7
+ used by skill files: scalars, inline and block lists, nested maps and
8
+ folded or literal block scalars.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
17
+
18
+ SKILL_FILENAME = "SKILL.md"
19
+ SKIP_DIRS = {
20
+ ".git", ".hg", ".svn", "__pycache__", "node_modules", ".venv", "venv",
21
+ "dist", "build", ".mypy_cache", ".pytest_cache", ".idea", ".vscode",
22
+ ".tox", ".eggs",
23
+ }
24
+ SKIP_SUFFIXES = {".pyc", ".pyo"}
25
+ FRONTMATTER_ORDER = [
26
+ "name", "description", "license", "compatibility", "metadata", "allowed-tools",
27
+ ]
28
+
29
+
30
+ class SkillError(Exception):
31
+ """Raised when a skill cannot be located or read."""
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Minimal YAML subset
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _KEY_RE = re.compile(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*)\s*:(?:\s+(.*))?$")
39
+
40
+
41
+ def _strip_comment(value: str) -> str:
42
+ """Remove a trailing ' # comment' that is not inside quotes."""
43
+ quote = ""
44
+ for i, ch in enumerate(value):
45
+ if quote:
46
+ if ch == quote:
47
+ quote = ""
48
+ elif ch in "\"'":
49
+ quote = ch
50
+ elif ch == "#" and (i == 0 or value[i - 1] in " \t"):
51
+ return value[:i].rstrip()
52
+ return value
53
+
54
+
55
+ def _split_inline(text: str) -> List[str]:
56
+ parts: List[str] = []
57
+ buf: List[str] = []
58
+ quote = ""
59
+ for ch in text:
60
+ if quote:
61
+ buf.append(ch)
62
+ if ch == quote:
63
+ quote = ""
64
+ elif ch in "\"'":
65
+ quote = ch
66
+ buf.append(ch)
67
+ elif ch == ",":
68
+ parts.append("".join(buf))
69
+ buf = []
70
+ else:
71
+ buf.append(ch)
72
+ tail = "".join(buf)
73
+ if tail.strip() or parts:
74
+ parts.append(tail)
75
+ return parts
76
+
77
+
78
+ def parse_scalar(raw: str) -> Any:
79
+ s = raw.strip()
80
+ if s == "":
81
+ return ""
82
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
83
+ inner = s[1:-1]
84
+ if s[0] == '"':
85
+ inner = (inner.replace('\\"', '"').replace("\\n", "\n")
86
+ .replace("\\t", "\t").replace("\\\\", "\\"))
87
+ else:
88
+ inner = inner.replace("''", "'")
89
+ return inner
90
+ if s.startswith("[") and s.endswith("]"):
91
+ return [parse_scalar(p) for p in _split_inline(s[1:-1]) if p.strip()]
92
+ low = s.lower()
93
+ if low in ("true", "false"):
94
+ return low == "true"
95
+ if low in ("null", "~"):
96
+ return None
97
+ return s
98
+
99
+
100
+ def _fold_block(block: List[str], style: str) -> str:
101
+ lines = [ln.rstrip("\r\n") for ln in block]
102
+ indents = [len(ln) - len(ln.lstrip()) for ln in lines if ln.strip()]
103
+ cut = min(indents) if indents else 0
104
+ lines = [ln[cut:] if ln.strip() else "" for ln in lines]
105
+ if style.startswith("|"):
106
+ text = "\n".join(lines)
107
+ else:
108
+ out: List[str] = []
109
+ para: List[str] = []
110
+ for ln in lines:
111
+ if ln == "":
112
+ if para:
113
+ out.append(" ".join(para))
114
+ para = []
115
+ out.append("")
116
+ else:
117
+ para.append(ln.strip())
118
+ if para:
119
+ out.append(" ".join(para))
120
+ text = "\n".join(out)
121
+ return text.strip()
122
+
123
+
124
+ def parse_yaml(lines: List[str], line_offset: int = 0) -> Tuple[Dict[str, Any], List[Tuple[int, str]]]:
125
+ """Parse a YAML subset. Returns (mapping, [(line_number, message)])."""
126
+ result: Dict[str, Any] = {}
127
+ errors: List[Tuple[int, str]] = []
128
+ i = 0
129
+ n = len(lines)
130
+ while i < n:
131
+ raw = lines[i]
132
+ stripped = raw.strip()
133
+ if not stripped or stripped.startswith("#"):
134
+ i += 1
135
+ continue
136
+ if raw[0] in " \t":
137
+ errors.append((line_offset + i + 1, "unexpected indentation"))
138
+ i += 1
139
+ continue
140
+ m = _KEY_RE.match(raw.rstrip())
141
+ if not m:
142
+ errors.append((line_offset + i + 1, "cannot parse line: %s" % stripped[:60]))
143
+ i += 1
144
+ continue
145
+ key = m.group(1)
146
+ value = _strip_comment(m.group(2) or "").strip()
147
+ i += 1
148
+ if value in (">", "|", ">-", "|-", ">+", "|+"):
149
+ block: List[str] = []
150
+ while i < n and (not lines[i].strip() or lines[i][0] in " \t"):
151
+ block.append(lines[i])
152
+ i += 1
153
+ result[key] = _fold_block(block, value)
154
+ elif value == "":
155
+ block = []
156
+ while i < n and (not lines[i].strip() or lines[i][0] in " \t" or lines[i].startswith("- ")):
157
+ block.append(lines[i])
158
+ i += 1
159
+ first = next((b for b in block if b.strip()), "")
160
+ if not first:
161
+ result[key] = ""
162
+ elif first.lstrip().startswith("- "):
163
+ items = []
164
+ for b in block:
165
+ bs = b.strip()
166
+ if bs.startswith("- "):
167
+ items.append(parse_scalar(_strip_comment(bs[2:])))
168
+ result[key] = items
169
+ else:
170
+ cut = min(len(b) - len(b.lstrip()) for b in block if b.strip())
171
+ nested, nested_errors = parse_yaml([b[cut:] for b in block], line_offset + i - len(block))
172
+ errors.extend(nested_errors)
173
+ result[key] = nested
174
+ else:
175
+ result[key] = parse_scalar(value)
176
+ return result, errors
177
+
178
+
179
+ def _needs_quotes(s: str) -> bool:
180
+ if s == "" or s != s.strip():
181
+ return True
182
+ if s[0] in "-?:,[]{}#&*!|>'\"%@`":
183
+ return True
184
+ if ": " in s or " #" in s or "\n" in s or s.endswith(":"):
185
+ return True
186
+ return s.lower() in ("true", "false", "null", "~", "yes", "no")
187
+
188
+
189
+ def _dump_scalar(v: Any) -> str:
190
+ if isinstance(v, bool):
191
+ return "true" if v else "false"
192
+ if v is None:
193
+ return "null"
194
+ s = str(v)
195
+ if _needs_quotes(s):
196
+ return '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
197
+ return s
198
+
199
+
200
+ def dump_frontmatter(data: Dict[str, Any]) -> str:
201
+ """Serialise a mapping to a frontmatter block (including the --- fences)."""
202
+ keys = [k for k in FRONTMATTER_ORDER if k in data] + [k for k in data if k not in FRONTMATTER_ORDER]
203
+ out = ["---"]
204
+ for key in keys:
205
+ val = data[key]
206
+ if isinstance(val, dict):
207
+ out.append("%s:" % key)
208
+ for k2, v2 in val.items():
209
+ if isinstance(v2, list):
210
+ out.append(" %s: [%s]" % (k2, ", ".join(_dump_scalar(x) for x in v2)))
211
+ else:
212
+ out.append(" %s: %s" % (k2, _dump_scalar(v2)))
213
+ elif isinstance(val, list):
214
+ out.append("%s: [%s]" % (key, ", ".join(_dump_scalar(x) for x in val)))
215
+ else:
216
+ out.append("%s: %s" % (key, _dump_scalar(val)))
217
+ out.append("---")
218
+ return "\n".join(out) + "\n"
219
+
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # Skill model
223
+ # ---------------------------------------------------------------------------
224
+
225
+ @dataclass
226
+ class Skill:
227
+ path: Path
228
+ file: Path
229
+ raw: str
230
+ meta: Dict[str, Any] = field(default_factory=dict)
231
+ body: str = ""
232
+ body_line: int = 1
233
+ has_frontmatter: bool = False
234
+ fm_errors: List[Tuple[int, str]] = field(default_factory=list)
235
+ fm_lines: List[str] = field(default_factory=list)
236
+
237
+ @property
238
+ def name(self) -> str:
239
+ return str(self.meta.get("name") or self.path.name)
240
+
241
+ @property
242
+ def description(self) -> str:
243
+ d = self.meta.get("description")
244
+ return d if isinstance(d, str) else ""
245
+
246
+ @property
247
+ def metadata(self) -> Dict[str, Any]:
248
+ m = self.meta.get("metadata")
249
+ return m if isinstance(m, dict) else {}
250
+
251
+ @property
252
+ def version(self) -> str:
253
+ v = self.metadata.get("version") or self.meta.get("version") or ""
254
+ return str(v)
255
+
256
+ @property
257
+ def author(self) -> str:
258
+ return str(self.metadata.get("author") or self.meta.get("author") or "")
259
+
260
+ @property
261
+ def license(self) -> str:
262
+ return str(self.meta.get("license") or "")
263
+
264
+ @property
265
+ def tags(self) -> List[str]:
266
+ raw = self.metadata.get("tags") or self.meta.get("tags") or []
267
+ if isinstance(raw, str):
268
+ raw = [p for p in re.split(r"[,\s]+", raw) if p]
269
+ return [str(t).strip() for t in raw if str(t).strip()]
270
+
271
+
272
+ def split_frontmatter(text: str) -> Tuple[Optional[List[str]], str, int]:
273
+ """Return (frontmatter_lines or None, body, body_start_line)."""
274
+ if text.startswith("\ufeff"):
275
+ text = text[1:]
276
+ lines = text.splitlines()
277
+ if not lines or lines[0].strip() != "---":
278
+ return None, text, 1
279
+ for idx in range(1, len(lines)):
280
+ if lines[idx].strip() == "---":
281
+ body = "\n".join(lines[idx + 1:])
282
+ return lines[1:idx], body, idx + 2
283
+ return None, text, 1
284
+
285
+
286
+ def parse_skill_text(text: str, path: Optional[Path] = None) -> Skill:
287
+ path = path or Path(".")
288
+ fm_lines, body, body_line = split_frontmatter(text)
289
+ skill = Skill(path=path, file=path / SKILL_FILENAME, raw=text, body=body, body_line=body_line)
290
+ if fm_lines is None:
291
+ return skill
292
+ skill.has_frontmatter = True
293
+ skill.fm_lines = fm_lines
294
+ meta, errors = parse_yaml(fm_lines, line_offset=1)
295
+ skill.meta = meta
296
+ skill.fm_errors = errors
297
+ return skill
298
+
299
+
300
+ def load_skill(target: Any) -> Skill:
301
+ """Load a skill from a directory or a SKILL.md path."""
302
+ p = Path(target).expanduser()
303
+ if p.is_file():
304
+ if p.name.lower() != SKILL_FILENAME.lower():
305
+ raise SkillError("%s is not a SKILL.md file" % p)
306
+ p = p.parent
307
+ if not p.is_dir():
308
+ raise SkillError("not found: %s" % target)
309
+ skill_file = p / SKILL_FILENAME
310
+ if not skill_file.exists():
311
+ alt = next((c for c in p.iterdir() if c.name.lower() == "skill.md"), None) if p.exists() else None
312
+ if alt is None:
313
+ raise SkillError("no %s in %s" % (SKILL_FILENAME, p))
314
+ skill_file = alt
315
+ try:
316
+ text = skill_file.read_text(encoding="utf-8")
317
+ except (OSError, UnicodeDecodeError) as exc:
318
+ raise SkillError("cannot read %s: %s" % (skill_file, exc))
319
+ skill = parse_skill_text(text, p.resolve())
320
+ skill.file = skill_file
321
+ return skill
322
+
323
+
324
+ def discover_skills(root: Any, max_depth: int = 5) -> List[Path]:
325
+ """Find every directory below root that holds a SKILL.md."""
326
+ base = Path(root).expanduser()
327
+ found: List[Path] = []
328
+ if not base.exists():
329
+ return found
330
+ if base.is_file():
331
+ return [base.parent] if base.name.lower() == "skill.md" else []
332
+
333
+ def walk(d: Path, depth: int) -> None:
334
+ try:
335
+ entries = sorted(d.iterdir(), key=lambda e: e.name)
336
+ except OSError:
337
+ return
338
+ if any(e.is_file() and e.name.lower() == "skill.md" for e in entries):
339
+ found.append(d)
340
+ return
341
+ if depth >= max_depth:
342
+ return
343
+ for e in entries:
344
+ if e.is_dir() and e.name not in SKIP_DIRS and not e.name.startswith("."):
345
+ walk(e, depth + 1)
346
+
347
+ walk(base, 0)
348
+ return found
349
+
350
+
351
+ def iter_skill_files(skill_dir: Any) -> List[Path]:
352
+ """All files of a skill (relative paths), excluding caches."""
353
+ base = Path(skill_dir)
354
+ out: List[Path] = []
355
+ for p in sorted(base.rglob("*")):
356
+ if not p.is_file():
357
+ continue
358
+ rel = p.relative_to(base)
359
+ if any(part in SKIP_DIRS for part in rel.parts[:-1]) or p.suffix in SKIP_SUFFIXES:
360
+ continue
361
+ out.append(rel)
362
+ # Sort by the posix string: Path ordering is case-insensitive on Windows, which
363
+ # would change the file order (and so the registry index) between systems.
364
+ return sorted(out, key=lambda p: p.as_posix())
365
+
366
+
367
+ def slugify(text: str, max_len: int = 64) -> str:
368
+ s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
369
+ s = re.sub(r"-{2,}", "-", s)
370
+ return s[:max_len].strip("-") or "new-skill"
371
+
372
+
373
+ def as_list(value: Any) -> List[str]:
374
+ if value is None:
375
+ return []
376
+ if isinstance(value, list):
377
+ return [str(v) for v in value]
378
+ return [p for p in re.split(r"[,\s]+", str(value)) if p]
@@ -0,0 +1,275 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>__TITLE__</title>
7
+ <meta name="description" content="Search, vet and install agent skills. Every skill is linted, security scanned and tested.">
8
+ <style>
9
+ :root{
10
+ --bg:#e9edf1; --surface:#ffffff; --ink:#0f1b26; --muted:#55626f; --line:#cbd3db; --accent:#2b4bdb;
11
+ --accent-ink:#ffffff; --code:#eef1f5; --a:#0e7a5f; --b:#3f7d1c; --c:#9a6700; --d:#b4540a; --f:#b3261e;
12
+ --shadow:0 1px 0 rgba(15,27,38,.04);
13
+ --font:"Avenir Next","Segoe UI Variable Text","Segoe UI",system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;
14
+ --mono:ui-monospace,"Cascadia Code","SF Mono",Menlo,Consolas,monospace;
15
+ color-scheme:light;
16
+ }
17
+ @media (prefers-color-scheme:dark){
18
+ :root:not([data-theme="light"]){
19
+ --bg:#0e141b; --surface:#151d26; --ink:#e6edf3; --muted:#93a1af; --line:#27323d; --accent:#7c96ff;
20
+ --accent-ink:#0e141b; --code:#0f161d; --a:#4cc9a6; --b:#9bd35a; --c:#e0b341; --d:#f08a45; --f:#ff7b72;
21
+ --shadow:none; color-scheme:dark;
22
+ }
23
+ }
24
+ :root[data-theme="dark"]{
25
+ --bg:#0e141b; --surface:#151d26; --ink:#e6edf3; --muted:#93a1af; --line:#27323d; --accent:#7c96ff;
26
+ --accent-ink:#0e141b; --code:#0f161d; --a:#4cc9a6; --b:#9bd35a; --c:#e0b341; --d:#f08a45; --f:#ff7b72;
27
+ --shadow:none; color-scheme:dark;
28
+ }
29
+ *{box-sizing:border-box}
30
+ html{scroll-padding-top:env(safe-area-inset-top,0px)}
31
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 var(--font);
32
+ padding:env(safe-area-inset-top,0px) 0 env(safe-area-inset-bottom,0px)}
33
+ a{color:var(--accent)}
34
+ button{font:inherit;color:inherit;cursor:pointer}
35
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
36
+ .wrap{max-width:1180px;margin:0 auto;padding:0 24px}
37
+ header.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 0}
38
+ .brand{font-weight:700;font-size:17px;letter-spacing:-.01em}
39
+ .brand small{font-weight:500;color:var(--muted);margin-left:10px;font-size:14px}
40
+ nav.links{display:flex;gap:18px;font-size:14px}
41
+ nav.links a{color:var(--muted);text-decoration:none}
42
+ nav.links a:hover{color:var(--ink);text-decoration:underline}
43
+ .hero{padding:34px 0 18px}
44
+ .hero h1{margin:0 0 18px;font-size:clamp(30px,5vw,50px);line-height:1.06;letter-spacing:-.025em;font-weight:700;max-width:16em}
45
+ .search{display:flex;align-items:center;background:var(--surface);border:1.5px solid var(--ink);border-radius:6px;
46
+ padding:0 16px;box-shadow:var(--shadow)}
47
+ .search input{flex:1;border:0;background:transparent;color:var(--ink);font:inherit;font-size:20px;padding:18px 0;min-width:0}
48
+ .search input:focus{outline:none}
49
+ .search:focus-within{outline:2px solid var(--accent);outline-offset:2px}
50
+ .search kbd{font:12px var(--mono);color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:2px 7px}
51
+ .cmdline{display:flex;align-items:center;gap:12px;margin-top:12px;padding:11px 14px;background:var(--code);
52
+ border:1px solid var(--line);border-radius:6px;font:14px var(--mono);overflow:hidden}
53
+ .cmdline code{flex:1;white-space:nowrap;overflow:auto;scrollbar-width:none}
54
+ .cmdline code::-webkit-scrollbar{display:none}
55
+ .copy{border:1px solid var(--line);background:var(--surface);border-radius:5px;padding:4px 10px;font-size:13px}
56
+ .copy:hover{border-color:var(--ink)}
57
+ .filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:18px 0 6px}
58
+ .chip{border:1px solid var(--line);background:transparent;border-radius:999px;padding:4px 12px;font-size:14px;color:var(--muted)}
59
+ .chip:hover{color:var(--ink);border-color:var(--ink)}
60
+ .chip[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
61
+ .sort{margin-left:auto;font-size:14px;color:var(--muted)}
62
+ .sort select{background:var(--surface);color:var(--ink);border:1px solid var(--line);border-radius:5px;padding:4px 8px;font:inherit}
63
+ .layout{display:grid;grid-template-columns:minmax(0,1fr);gap:24px;padding-bottom:64px}
64
+ .layout.open{grid-template-columns:minmax(0,1fr) minmax(0,520px)}
65
+ .count{font-size:14px;color:var(--muted);margin:10px 0}
66
+ .list{background:var(--surface);border:1px solid var(--line);border-radius:6px;overflow:hidden}
67
+ .row{display:grid;grid-template-columns:52px minmax(0,1fr) auto;gap:16px;align-items:start;width:100%;text-align:left;
68
+ padding:16px 18px;border:0;border-bottom:1px solid var(--line);background:transparent}
69
+ .row:last-child{border-bottom:0}
70
+ .row:hover,.row[aria-current="true"]{background:color-mix(in srgb,var(--accent) 7%,transparent)}
71
+ .grade{width:44px;height:44px;border:2px solid currentColor;border-radius:6px;display:grid;place-items:center;
72
+ font-weight:800;font-size:20px}
73
+ .g-A{color:var(--a)}.g-B{color:var(--b)}.g-C{color:var(--c)}.g-D{color:var(--d)}.g-F{color:var(--f)}
74
+ .name{font-weight:650;font-size:17px;letter-spacing:-.01em}
75
+ .desc{color:var(--muted);font-size:15px;margin:2px 0 8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
76
+ .tags{display:flex;flex-wrap:wrap;gap:6px}
77
+ .tag{font-size:12.5px;color:var(--muted);border:1px solid var(--line);border-radius:4px;padding:0 7px}
78
+ .facts{font-size:13px;color:var(--muted);text-align:right;white-space:nowrap;line-height:1.6}
79
+ .facts b{color:var(--ink);font-weight:600}
80
+ .empty{padding:36px 18px;color:var(--muted)}
81
+ aside.panel{position:sticky;top:12px;align-self:start;max-height:calc(100vh - 24px);overflow:auto;background:var(--surface);
82
+ border:1px solid var(--line);border-radius:6px;padding:22px}
83
+ aside.panel[hidden]{display:none}
84
+ .panel h2{margin:0;font-size:24px;letter-spacing:-.02em}
85
+ .panel .meta{color:var(--muted);font-size:14px;margin:4px 0 14px}
86
+ .close{float:right;border:1px solid var(--line);background:transparent;border-radius:5px;padding:2px 10px;font-size:14px}
87
+ .checks{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:14px 0}
88
+ .check{border:1px solid var(--line);border-radius:6px;padding:10px 12px;font-size:13px;color:var(--muted)}
89
+ .check b{display:block;font-size:18px;color:var(--ink)}
90
+ .install{margin:14px 0}
91
+ .install .tabs{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
92
+ .install .tabs button{border:1px solid var(--line);background:transparent;border-radius:5px;padding:2px 9px;font-size:13px;color:var(--muted)}
93
+ .install .tabs button[aria-pressed="true"]{border-color:var(--ink);color:var(--ink)}
94
+ .md h1,.md h2,.md h3{letter-spacing:-.01em;line-height:1.25;margin:20px 0 8px}
95
+ .md h1{font-size:20px}.md h2{font-size:17px}.md h3{font-size:15px}
96
+ .md p,.md li{font-size:15px;max-width:70ch}
97
+ .md code{font:13px var(--mono);background:var(--code);border-radius:4px;padding:1px 5px}
98
+ .md pre{background:var(--code);border:1px solid var(--line);border-radius:6px;padding:12px;overflow:auto}
99
+ .md pre code{background:none;padding:0}
100
+ .md blockquote{margin:10px 0;padding-left:12px;border-left:3px solid var(--line);color:var(--muted)}
101
+ .files{font:13px var(--mono);color:var(--muted);margin:6px 0 0;padding-left:18px}
102
+ footer{border-top:1px solid var(--line);color:var(--muted);font-size:14px;padding:22px 0 40px}
103
+ @media (max-width:900px){
104
+ .layout.open{grid-template-columns:minmax(0,1fr)}
105
+ aside.panel{position:fixed;inset:env(safe-area-inset-top,0px) 0 0 0;max-height:none;z-index:5;border-radius:0;
106
+ padding-bottom:calc(22px + env(safe-area-inset-bottom,0px))}
107
+ .row{grid-template-columns:44px minmax(0,1fr)}
108
+ .facts{grid-column:2;text-align:left}
109
+ .wrap{padding:0 16px}
110
+ }
111
+ @media (prefers-reduced-motion:no-preference){aside.panel{animation:slide .16s ease-out}}
112
+ @keyframes slide{from{opacity:.4;transform:translateX(10px)}to{opacity:1;transform:none}}
113
+ </style>
114
+ </head>
115
+ <body>
116
+ <div class="wrap">
117
+ <header class="top">
118
+ <div class="brand">agentskill-kit<small id="meta"></small></div>
119
+ <nav class="links">
120
+ <a href="https://github.com/Lethe044/agentskill-kit">GitHub</a>
121
+ <a href="https://pypi.org/project/agentskill-toolkit/">PyPI</a>
122
+ <a href="https://github.com/Lethe044/agentskill-kit/blob/main/CONTRIBUTING.md">Add a skill</a>
123
+ </nav>
124
+ </header>
125
+ <section class="hero">
126
+ <h1>Agent skills you can check before you install them</h1>
127
+ <label class="search"><input id="q" type="search" placeholder="Search skills: logs, docker, commit, csv" autocomplete="off" aria-label="Search skills"><kbd>/</kbd></label>
128
+ <div class="cmdline"><code id="cmd">pip install agentskill-toolkit</code><button class="copy" id="copycmd" type="button">Copy</button></div>
129
+ <div class="filters" id="filters"></div>
130
+ </section>
131
+ <div class="layout" id="layout">
132
+ <main>
133
+ <div class="count" id="count"></div>
134
+ <div class="list" id="list"></div>
135
+ </main>
136
+ <aside class="panel" id="panel" hidden aria-label="Skill details"></aside>
137
+ </div>
138
+ <footer>Scores combine lint, security scan, bundled tests and documentation. They are signals, not guarantees: read a skill before you let an agent run it.</footer>
139
+ </div>
140
+ <script id="askit-data" type="application/json">__DATA__</script>
141
+ <script>
142
+ (function(){
143
+ var DATA = JSON.parse(document.getElementById('askit-data').textContent);
144
+ var skills = DATA.skills || [];
145
+ var state = {q:'', tag:'', sort:'score', open:null, target:'claude'};
146
+ var TARGETS = ['claude','cursor','codex','copilot','windsurf','gemini','hermes'];
147
+ var $ = function(id){return document.getElementById(id)};
148
+ function esc(s){return String(s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]})}
149
+ function tokens(s){return String(s).toLowerCase().match(/[a-z0-9]+/g)||[]}
150
+
151
+ function score(s, q){
152
+ var t = tokens(q); if(!t.length) return 1;
153
+ var n = tokens(s.name.replace(/-/g,' ')), g = tokens((s.tags||[]).join(' ')), d = tokens(s.description||'');
154
+ var total = 0;
155
+ t.forEach(function(w){
156
+ var v = 0;
157
+ if(n.indexOf(w)>-1) v += 3; if(g.indexOf(w)>-1) v += 2; if(d.indexOf(w)>-1) v += 1;
158
+ if(!v){ if(n.concat(g).some(function(x){return x.indexOf(w)===0})) v += 1.5;
159
+ else if(d.some(function(x){return x.indexOf(w)===0})) v += .5; }
160
+ total += v;
161
+ });
162
+ return total;
163
+ }
164
+ function visible(){
165
+ var out = skills.filter(function(s){ return !state.tag || (s.tags||[]).indexOf(state.tag)>-1; })
166
+ .map(function(s){return {s:s, k:score(s,state.q)}}).filter(function(x){return x.k>0});
167
+ out.sort(function(a,b){
168
+ if(state.q && b.k!==a.k) return b.k-a.k;
169
+ if(state.sort==='name') return a.s.name.localeCompare(b.s.name);
170
+ return (b.s.score||0)-(a.s.score||0) || a.s.name.localeCompare(b.s.name);
171
+ });
172
+ return out.map(function(x){return x.s});
173
+ }
174
+ function installCmd(s, target){
175
+ return 'askit install ' + s.name + (target && target!=='claude' ? ' --target ' + target : '');
176
+ }
177
+ function md(src){
178
+ var lines = String(src||'').replace(/\r/g,'').split('\n'), out = [], i = 0, list = null, para = [];
179
+ function inline(t){
180
+ t = esc(t);
181
+ t = t.replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
182
+ .replace(/(^|[\s(])\*([^*\s][^*]*)\*/g,'$1<em>$2</em>')
183
+ .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" rel="noopener">$1</a>');
184
+ return t;
185
+ }
186
+ function flushP(){ if(para.length){ out.push('<p>'+inline(para.join(' '))+'</p>'); para=[]; } }
187
+ function flushL(){ if(list){ out.push('</'+list+'>'); list=null; } }
188
+ while(i<lines.length){
189
+ var ln = lines[i], m;
190
+ if(/^\s*```/.test(ln)){
191
+ flushP(); flushL(); var buf=[]; i++;
192
+ while(i<lines.length && !/^\s*```/.test(lines[i])){ buf.push(lines[i]); i++; }
193
+ out.push('<pre><code>'+esc(buf.join('\n'))+'</code></pre>'); i++; continue;
194
+ }
195
+ if((m = ln.match(/^(#{1,3})\s+(.*)$/))){ flushP(); flushL(); out.push('<h'+m[1].length+'>'+inline(m[2])+'</h'+m[1].length+'>'); i++; continue; }
196
+ if((m = ln.match(/^\s*[-*]\s+(.*)$/))){ flushP(); if(list!=='ul'){flushL(); out.push('<ul>'); list='ul';} out.push('<li>'+inline(m[1])+'</li>'); i++; continue; }
197
+ if((m = ln.match(/^\s*\d+\.\s+(.*)$/))){ flushP(); if(list!=='ol'){flushL(); out.push('<ol>'); list='ol';} out.push('<li>'+inline(m[1])+'</li>'); i++; continue; }
198
+ if((m = ln.match(/^>\s?(.*)$/))){ flushP(); flushL(); out.push('<blockquote>'+inline(m[1])+'</blockquote>'); i++; continue; }
199
+ if(!ln.trim()){ flushP(); flushL(); i++; continue; }
200
+ if(list && /^\s+/.test(ln)){ out[out.length-1] = out[out.length-1].replace(/<\/li>$/,' '+inline(ln.trim())+'</li>'); i++; continue; }
201
+ flushL(); para.push(ln.trim()); i++;
202
+ }
203
+ flushP(); flushL(); return out.join('\n');
204
+ }
205
+ function copy(text, btn){
206
+ function done(){ var o = btn.textContent; btn.textContent = 'Copied'; setTimeout(function(){btn.textContent = o}, 1200); }
207
+ if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(done, fallback); } else fallback();
208
+ function fallback(){ var t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select();
209
+ try{document.execCommand('copy'); done();}catch(e){} document.body.removeChild(t); }
210
+ }
211
+ function renderFilters(){
212
+ var counts = {}; skills.forEach(function(s){(s.tags||[]).forEach(function(t){counts[t]=(counts[t]||0)+1})});
213
+ var tags = Object.keys(counts).sort(function(a,b){return counts[b]-counts[a]||a.localeCompare(b)}).slice(0,14);
214
+ var html = '<button class="chip" type="button" data-tag="" aria-pressed="'+(!state.tag)+'">All</button>';
215
+ tags.forEach(function(t){ html += '<button class="chip" type="button" data-tag="'+esc(t)+'" aria-pressed="'+(state.tag===t)+'">'+esc(t)+'</button>'; });
216
+ html += '<label class="sort">Sort <select id="sort"><option value="score">Quality</option><option value="name">Name</option></select></label>';
217
+ $('filters').innerHTML = html; $('sort').value = state.sort;
218
+ }
219
+ function renderList(){
220
+ var items = visible();
221
+ $('count').textContent = items.length + (items.length===1 ? ' skill' : ' skills') + (state.q ? ' for "' + state.q + '"' : '');
222
+ if(!items.length){ $('list').innerHTML = '<div class="empty">Nothing matches. Try a broader word, or clear the tag filter.</div>'; updateCmd(null); return; }
223
+ $('list').innerHTML = items.map(function(s){
224
+ var tests = s.tested ? (s.tests_passed + '/' + s.tests_total + ' tests') : 'instructions only';
225
+ return '<button class="row" type="button" data-name="'+esc(s.name)+'" aria-current="'+(state.open===s.name)+'">'+
226
+ '<div class="grade g-'+esc(s.grade)+'" title="Quality score '+Math.round((s.score||0)*100)+'">'+esc(s.grade)+'</div>'+
227
+ '<div><div class="name">'+esc(s.name)+'</div><div class="desc">'+esc(s.description)+'</div>'+
228
+ '<div class="tags">'+(s.tags||[]).slice(0,5).map(function(t){return '<span class="tag">'+esc(t)+'</span>'}).join('')+'</div></div>'+
229
+ '<div class="facts"><b>'+Math.round((s.score||0)*100)+'</b> score<br>'+esc(tests)+'<br>v'+esc(s.version||'0.0.0')+'</div></button>';
230
+ }).join('');
231
+ updateCmd(items[0]);
232
+ }
233
+ function updateCmd(s){ $('cmd').textContent = s ? installCmd(s, state.target) : 'pip install agentskill-toolkit'; }
234
+ function renderPanel(){
235
+ var s = skills.filter(function(x){return x.name===state.open})[0], p = $('panel');
236
+ if(!s){ p.hidden = true; $('layout').className = 'layout'; return; }
237
+ p.hidden = false; $('layout').className = 'layout open';
238
+ var sc = s.scan || {high:0,medium:0,low:0};
239
+ p.innerHTML = '<button class="close" type="button" id="close">Close</button>'+
240
+ '<h2>'+esc(s.name)+'</h2><div class="meta">Version '+esc(s.version||'0.0.0')+(s.author?' by '+esc(s.author):'')+(s.license?', '+esc(s.license):'')+'</div>'+
241
+ '<p>'+esc(s.description)+'</p>'+
242
+ '<div class="checks"><div class="check"><b class="g-'+esc(s.grade)+'">'+esc(s.grade)+' ('+Math.round((s.score||0)*100)+')</b>quality</div>'+
243
+ '<div class="check"><b>'+(s.tested ? s.tests_passed+'/'+s.tests_total : 'none')+'</b>bundled tests</div>'+
244
+ '<div class="check"><b>'+sc.high+' high, '+sc.medium+' medium</b>security findings</div></div>'+
245
+ '<div class="install"><div class="tabs">'+TARGETS.map(function(t){return '<button type="button" data-target="'+t+'" aria-pressed="'+(state.target===t)+'">'+t+'</button>'}).join('')+'</div>'+
246
+ '<div class="cmdline"><code>'+esc(installCmd(s, state.target))+'</code><button class="copy" type="button" id="copyinstall">Copy</button></div></div>'+
247
+ '<div class="md">'+md(s.body)+'</div>'+
248
+ '<h3>Files</h3><ul class="files">'+(s.files||[]).map(function(f){return '<li>'+esc(f.path)+'</li>'}).join('')+'</ul>';
249
+ $('close').onclick = function(){ state.open = null; renderPanel(); renderList(); };
250
+ $('copyinstall').onclick = function(){ copy(installCmd(s, state.target), $('copyinstall')); };
251
+ Array.prototype.forEach.call(p.querySelectorAll('[data-target]'), function(b){
252
+ b.onclick = function(){ state.target = b.getAttribute('data-target'); renderPanel(); updateCmd(s); };
253
+ });
254
+ }
255
+ $('q').addEventListener('input', function(e){ state.q = e.target.value; renderList(); });
256
+ $('q').addEventListener('keydown', function(e){ if(e.key==='Enter'){ var v = visible()[0]; if(v){ state.open = v.name; renderPanel(); renderList(); } } });
257
+ $('list').addEventListener('click', function(e){
258
+ var r = e.target.closest('.row'); if(!r) return; state.open = r.getAttribute('data-name'); renderPanel(); renderList();
259
+ if(window.innerWidth<=900) $('panel').scrollTop = 0;
260
+ });
261
+ $('filters').addEventListener('click', function(e){
262
+ var c = e.target.closest('[data-tag]'); if(!c) return; state.tag = c.getAttribute('data-tag'); renderFilters(); renderList();
263
+ });
264
+ $('filters').addEventListener('change', function(e){ if(e.target.id==='sort'){ state.sort = e.target.value; renderList(); } });
265
+ $('copycmd').onclick = function(){ copy($('cmd').textContent, $('copycmd')); };
266
+ document.addEventListener('keydown', function(e){
267
+ if(e.key==='/' && document.activeElement!==$('q')){ e.preventDefault(); $('q').focus(); }
268
+ if(e.key==='Escape' && state.open){ state.open = null; renderPanel(); renderList(); }
269
+ });
270
+ $('meta').textContent = skills.length + ' skills';
271
+ renderFilters(); renderList();
272
+ })();
273
+ </script>
274
+ </body>
275
+ </html>