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,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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|