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
agentskill_kit/forge.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Turn a task description into a tested skill with a language model.
|
|
2
|
+
|
|
3
|
+
The loop is driven by this module, not by the model, so it works with small
|
|
4
|
+
local models that cannot call tools reliably:
|
|
5
|
+
|
|
6
|
+
draft -> lint + scan + run tests -> score -> (feedback -> redraft) -> write
|
|
7
|
+
|
|
8
|
+
Every candidate is judged by the same checks used by ``askit lint``, ``scan``
|
|
9
|
+
and ``test``, so the score reflects real results, not the model's opinion.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
from .config import memory_path
|
|
23
|
+
from .providers import LLM
|
|
24
|
+
from .scoring import Quality, evaluate_skill
|
|
25
|
+
from .skillfile import discover_skills, load_skill, parse_skill_text, slugify, SkillError
|
|
26
|
+
|
|
27
|
+
ALLOWED_TOP = {"SKILL.md"}
|
|
28
|
+
ALLOWED_DIRS = {"scripts", "references", "assets", "tests", "evals"}
|
|
29
|
+
MAX_FILES = 12
|
|
30
|
+
MAX_FILE_BYTES = 120_000
|
|
31
|
+
|
|
32
|
+
SYSTEM_PROMPT = """You write agent skills. A skill is a directory with a SKILL.md file that an AI coding agent reads when a task matches. You are precise, concise and you only write things that work.
|
|
33
|
+
|
|
34
|
+
SKILL.md format:
|
|
35
|
+
- It starts with YAML frontmatter between --- lines.
|
|
36
|
+
- Required: name (lowercase letters, digits and single hyphens, max 64 characters, same as the directory name) and description (max 1024 characters).
|
|
37
|
+
- The description is written in the third person. It states what the skill does AND when an agent should use it ("Use when the user asks to ...").
|
|
38
|
+
- Optional: license, compatibility, metadata (version, author, tags as a list).
|
|
39
|
+
- After the frontmatter: short markdown instructions with headings (when to use, how to run, output, examples, error handling). Keep it under 150 lines.
|
|
40
|
+
|
|
41
|
+
If the task is best served by code, add scripts:
|
|
42
|
+
- Python 3 standard library only, no network access, no file writes outside the working directory.
|
|
43
|
+
- The script reads arguments or stdin and prints a result (JSON when the output is structured).
|
|
44
|
+
- Add tests/cases.json so the skill can be verified. Schema:
|
|
45
|
+
{
|
|
46
|
+
"entry": "scripts/tool.py",
|
|
47
|
+
"cases": [
|
|
48
|
+
{"name": "short name", "kind": "happy|edge|error", "args": ["--flag"], "stdin": "text",
|
|
49
|
+
"files": {"input.txt": "file content"}, "expect_exit": 0,
|
|
50
|
+
"stdout_contains": ["text"], "stdout_regex": "pattern", "stdout_json": {"key": "value"},
|
|
51
|
+
"stderr_contains": ["text"]}
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
- Include at least three cases: one happy path, one edge case, one error case.
|
|
55
|
+
- Only assert on output you are certain the script produces.
|
|
56
|
+
If the task is only about process or judgment (for example writing commit messages), write instructions only: no scripts, no tests.
|
|
57
|
+
|
|
58
|
+
Output format. Return every file in exactly this form and nothing else outside the blocks:
|
|
59
|
+
=== FILE: SKILL.md ===
|
|
60
|
+
(file content)
|
|
61
|
+
=== END FILE ===
|
|
62
|
+
=== FILE: scripts/tool.py ===
|
|
63
|
+
(file content)
|
|
64
|
+
=== END FILE ===
|
|
65
|
+
Do not wrap file content in code fences. Allowed paths: SKILL.md, scripts/, references/, assets/, tests/, evals/."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ForgeError(Exception):
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Candidate:
|
|
74
|
+
round: int
|
|
75
|
+
files: Dict[str, str]
|
|
76
|
+
name: str
|
|
77
|
+
quality: Optional[Quality] = None
|
|
78
|
+
notes: List[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ForgeResult:
|
|
83
|
+
name: str = ""
|
|
84
|
+
path: Optional[Path] = None
|
|
85
|
+
quality: Optional[Quality] = None
|
|
86
|
+
rounds: int = 0
|
|
87
|
+
accepted: bool = False
|
|
88
|
+
written: bool = False
|
|
89
|
+
files: Dict[str, str] = field(default_factory=dict)
|
|
90
|
+
provider: str = ""
|
|
91
|
+
model: str = ""
|
|
92
|
+
notes: List[str] = field(default_factory=list)
|
|
93
|
+
prompt_tokens: int = 0
|
|
94
|
+
completion_tokens: int = 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Parsing model output
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
_BLOCK_RE = re.compile(r"^=== FILE:\s*(.+?)\s*===[ \t]*\r?\n(.*?)\r?\n=== END FILE ===", re.S | re.M)
|
|
102
|
+
_FENCE_RE = re.compile(r"```[ \t]*(?:file:|path:)?[ \t]*([\w./-]+\.[A-Za-z0-9]+|SKILL\.md)[ \t]*\r?\n(.*?)\r?\n```", re.S)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _unwrap_fence(content: str) -> str:
|
|
106
|
+
m = re.match(r"^\s*```[\w-]*[ \t]*\r?\n(.*?)\r?\n```\s*$", content, re.S)
|
|
107
|
+
return m.group(1) if m else content
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _safe_path(path: str) -> Optional[str]:
|
|
111
|
+
p = path.strip().strip("`").replace("\\", "/").lstrip("./")
|
|
112
|
+
if not p or p.startswith("/") or ".." in p.split("/") or re.match(r"^[A-Za-z]:", p):
|
|
113
|
+
return None
|
|
114
|
+
if p in ALLOWED_TOP:
|
|
115
|
+
return p
|
|
116
|
+
parts = p.split("/")
|
|
117
|
+
if parts[0] in ALLOWED_DIRS and len(parts) >= 2 and len(parts) <= 4:
|
|
118
|
+
return p
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_files(reply: str) -> Tuple[Dict[str, str], List[str]]:
|
|
123
|
+
"""Extract files from a model reply. Returns (files, notes)."""
|
|
124
|
+
notes: List[str] = []
|
|
125
|
+
raw: List[Tuple[str, str]] = [(m.group(1), m.group(2)) for m in _BLOCK_RE.finditer(reply)]
|
|
126
|
+
if not raw:
|
|
127
|
+
raw = [(m.group(1), m.group(2)) for m in _FENCE_RE.finditer(reply)]
|
|
128
|
+
if not raw:
|
|
129
|
+
stripped = reply.strip()
|
|
130
|
+
fence = re.search(r"```(?:markdown|md)?\s*\r?\n(---\r?\n.*?)\r?\n```", stripped, re.S)
|
|
131
|
+
if fence:
|
|
132
|
+
raw = [("SKILL.md", fence.group(1))]
|
|
133
|
+
elif stripped.startswith("---"):
|
|
134
|
+
raw = [("SKILL.md", stripped)]
|
|
135
|
+
if not raw:
|
|
136
|
+
return {}, ["no files found in the reply"]
|
|
137
|
+
prefix = ""
|
|
138
|
+
for path, _ in raw:
|
|
139
|
+
norm = path.strip().strip("`").replace("\\", "/")
|
|
140
|
+
if norm.endswith("/SKILL.md"):
|
|
141
|
+
prefix = norm[: -len("SKILL.md")]
|
|
142
|
+
break
|
|
143
|
+
files: Dict[str, str] = {}
|
|
144
|
+
for path, content in raw:
|
|
145
|
+
norm = path.strip().strip("`").replace("\\", "/")
|
|
146
|
+
if prefix and norm.startswith(prefix):
|
|
147
|
+
norm = norm[len(prefix):]
|
|
148
|
+
safe = _safe_path(norm)
|
|
149
|
+
if not safe:
|
|
150
|
+
notes.append("ignored file with unsupported path: %s" % path.strip())
|
|
151
|
+
continue
|
|
152
|
+
body = _unwrap_fence(content)
|
|
153
|
+
if len(body.encode("utf-8")) > MAX_FILE_BYTES:
|
|
154
|
+
notes.append("ignored oversized file: %s" % safe)
|
|
155
|
+
continue
|
|
156
|
+
files[safe] = body.rstrip("\n") + "\n"
|
|
157
|
+
if len(files) >= MAX_FILES:
|
|
158
|
+
notes.append("too many files; extra files ignored")
|
|
159
|
+
break
|
|
160
|
+
return files, notes
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Prompts
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def build_task_prompt(task: str, context: str = "", name_hint: str = "", similar: Optional[List[str]] = None) -> str:
|
|
168
|
+
parts = ["Create a skill for this task:\n%s" % task.strip()]
|
|
169
|
+
if name_hint:
|
|
170
|
+
parts.append("Use this skill name: %s" % name_hint)
|
|
171
|
+
if context.strip():
|
|
172
|
+
parts.append("Source material to learn from (notes, transcript or runbook):\n---\n%s\n---" % context.strip()[:12000])
|
|
173
|
+
if similar:
|
|
174
|
+
parts.append("Skills that already exist locally (do not duplicate them, be clearly different):\n- "
|
|
175
|
+
+ "\n- ".join(similar))
|
|
176
|
+
parts.append("Return the files now, using the exact === FILE: path === format.")
|
|
177
|
+
return "\n\n".join(parts)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_feedback(candidate: Candidate, threshold: float) -> str:
|
|
181
|
+
q = candidate.quality
|
|
182
|
+
lines = ["The skill scored %.2f; the required score is %.2f. Fix these problems and return ALL files again "
|
|
183
|
+
"in the same === FILE: path === format." % (q.score if q else 0.0, threshold)]
|
|
184
|
+
if candidate.notes:
|
|
185
|
+
lines.append("Format notes: " + "; ".join(candidate.notes))
|
|
186
|
+
if q and q.lint:
|
|
187
|
+
issues = [i for i in q.lint.issues if i.severity in ("error", "warning")]
|
|
188
|
+
if issues:
|
|
189
|
+
lines.append("Lint issues:")
|
|
190
|
+
lines.extend("- [%s] %s (line %d)" % (i.code, i.message, i.line) for i in issues[:12])
|
|
191
|
+
if q and q.scan and q.scan.findings:
|
|
192
|
+
lines.append("Security findings (remove these patterns):")
|
|
193
|
+
lines.extend("- [%s] %s in %s line %d" % (f.rule, f.message, f.file, f.line)
|
|
194
|
+
for f in q.scan.findings[:8] if f.severity in ("high", "medium"))
|
|
195
|
+
if q and q.tests:
|
|
196
|
+
if q.tests.refused:
|
|
197
|
+
lines.append("Tests were not run: " + q.tests.refused)
|
|
198
|
+
failed = [c for c in q.tests.cases if not c.passed]
|
|
199
|
+
if failed:
|
|
200
|
+
lines.append("Failing tests:")
|
|
201
|
+
lines.extend("- %s: %s" % (c.name, c.detail) for c in failed[:8])
|
|
202
|
+
elif not q.tests.has_tests and any(p.startswith("scripts/") for p in candidate.files):
|
|
203
|
+
lines.append("The skill has scripts but no tests/cases.json. Add at least three cases.")
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Writing candidates
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _skill_name(files: Dict[str, str], fallback: str) -> str:
|
|
212
|
+
if "SKILL.md" in files:
|
|
213
|
+
parsed = parse_skill_text(files["SKILL.md"])
|
|
214
|
+
n = parsed.meta.get("name")
|
|
215
|
+
if isinstance(n, str) and n.strip():
|
|
216
|
+
return n.strip()
|
|
217
|
+
return slugify(fallback)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def write_files(base: Path, files: Dict[str, str]) -> None:
|
|
221
|
+
for rel, content in files.items():
|
|
222
|
+
target = base / rel
|
|
223
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
target.write_text(content, encoding="utf-8")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _similar_local(task: str, dirs: List[Path]) -> List[str]:
|
|
228
|
+
words = {w for w in re.findall(r"[a-z]{4,}", task.lower())}
|
|
229
|
+
hits: List[Tuple[int, str]] = []
|
|
230
|
+
for d in dirs:
|
|
231
|
+
for sd in discover_skills(d, max_depth=3):
|
|
232
|
+
try:
|
|
233
|
+
s = load_skill(sd)
|
|
234
|
+
except SkillError:
|
|
235
|
+
continue
|
|
236
|
+
blob = (s.name + " " + s.description).lower()
|
|
237
|
+
overlap = sum(1 for w in words if w in blob)
|
|
238
|
+
if overlap >= 2:
|
|
239
|
+
hits.append((overlap, "%s: %s" % (s.name, s.description[:100])))
|
|
240
|
+
hits.sort(reverse=True)
|
|
241
|
+
return [h[1] for h in hits[:5]]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def remember(entry: Dict[str, Any]) -> None:
|
|
245
|
+
try:
|
|
246
|
+
with open(memory_path(), "a", encoding="utf-8") as fh:
|
|
247
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
248
|
+
except OSError:
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Main entry point
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def forge(task: str, llm: LLM, out_dir: Any, threshold: float = 0.8, max_rounds: int = 3,
|
|
257
|
+
force: bool = False, dry_run: bool = False, context: str = "", name_hint: str = "",
|
|
258
|
+
check_duplicates: bool = True, log: Optional[Callable[[str], None]] = None,
|
|
259
|
+
work_dir: Optional[Path] = None) -> ForgeResult:
|
|
260
|
+
import tempfile
|
|
261
|
+
|
|
262
|
+
say = log or (lambda msg: None)
|
|
263
|
+
if not task.strip() and not context.strip():
|
|
264
|
+
raise ForgeError("describe the task with --task or provide --from-file")
|
|
265
|
+
out = Path(out_dir).expanduser()
|
|
266
|
+
similar = _similar_local(task + " " + context[:400], [out]) if check_duplicates else []
|
|
267
|
+
if similar:
|
|
268
|
+
say("Similar skills already exist: " + "; ".join(s.split(":")[0] for s in similar))
|
|
269
|
+
|
|
270
|
+
messages: List[Dict[str, str]] = [
|
|
271
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
272
|
+
{"role": "user", "content": build_task_prompt(task, context, name_hint, similar)},
|
|
273
|
+
]
|
|
274
|
+
result = ForgeResult(provider=llm.name, model=llm.model)
|
|
275
|
+
best: Optional[Candidate] = None
|
|
276
|
+
tmp_root = Path(tempfile.mkdtemp(prefix="askit-forge-")) if work_dir is None else Path(work_dir)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
for rnd in range(1, max(1, max_rounds) + 1):
|
|
280
|
+
say("round %d/%d: asking %s" % (rnd, max_rounds, llm.describe()))
|
|
281
|
+
reply = llm.chat(messages)
|
|
282
|
+
result.prompt_tokens += reply.prompt_tokens
|
|
283
|
+
result.completion_tokens += reply.completion_tokens
|
|
284
|
+
files, notes = parse_files(reply.content)
|
|
285
|
+
messages.append({"role": "assistant", "content": reply.content})
|
|
286
|
+
if "SKILL.md" not in files:
|
|
287
|
+
cand = Candidate(rnd, files, name_hint or slugify(task[:40]), None, notes + ["SKILL.md is missing"])
|
|
288
|
+
messages.append({"role": "user", "content":
|
|
289
|
+
"Your reply did not contain a SKILL.md file in the required format. "
|
|
290
|
+
"Return all files using === FILE: path === and === END FILE === markers."})
|
|
291
|
+
say("round %d: no SKILL.md in the reply" % rnd)
|
|
292
|
+
result.rounds = rnd
|
|
293
|
+
continue
|
|
294
|
+
name = _skill_name(files, name_hint or task[:40])
|
|
295
|
+
cdir = tmp_root / ("r%d" % rnd) / name
|
|
296
|
+
if cdir.exists():
|
|
297
|
+
shutil.rmtree(cdir)
|
|
298
|
+
cdir.mkdir(parents=True)
|
|
299
|
+
write_files(cdir, files)
|
|
300
|
+
quality = evaluate_skill(cdir)
|
|
301
|
+
cand = Candidate(rnd, files, name, quality, notes)
|
|
302
|
+
result.rounds = rnd
|
|
303
|
+
say("round %d: %s score %.2f (%s), lint %d errors %d warnings, safety %d, tests %s" % (
|
|
304
|
+
rnd, name, quality.score, quality.grade, len(quality.lint.errors), len(quality.lint.warnings),
|
|
305
|
+
quality.scan.safety if quality.scan else 0,
|
|
306
|
+
"%d/%d" % (quality.tests.passed, quality.tests.total) if quality.tests and quality.tests.has_tests
|
|
307
|
+
else "none"))
|
|
308
|
+
if best is None or (cand.quality and best.quality and cand.quality.score > best.quality.score):
|
|
309
|
+
best = cand
|
|
310
|
+
if quality.score >= threshold and quality.passes_gate:
|
|
311
|
+
result.accepted = True
|
|
312
|
+
break
|
|
313
|
+
if rnd < max_rounds:
|
|
314
|
+
messages.append({"role": "user", "content": build_feedback(cand, threshold)})
|
|
315
|
+
|
|
316
|
+
if best is None or best.quality is None:
|
|
317
|
+
raise ForgeError("the model did not produce a usable skill. Try another model with --model")
|
|
318
|
+
result.name = best.name
|
|
319
|
+
result.quality = best.quality
|
|
320
|
+
result.files = best.files
|
|
321
|
+
result.notes = best.notes
|
|
322
|
+
|
|
323
|
+
if not result.accepted and not force:
|
|
324
|
+
remember({"event": "rejected", "name": best.name, "score": round(best.quality.score, 3),
|
|
325
|
+
"rounds": result.rounds, "provider": llm.name, "model": llm.model,
|
|
326
|
+
"task": task[:200], "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
|
327
|
+
return result
|
|
328
|
+
if dry_run:
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
dest = out / best.name
|
|
332
|
+
if dest.exists():
|
|
333
|
+
if not force:
|
|
334
|
+
raise ForgeError("%s already exists. Use --force to overwrite or choose another --name" % dest)
|
|
335
|
+
shutil.rmtree(dest)
|
|
336
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
337
|
+
write_files(dest, best.files)
|
|
338
|
+
result.path = dest
|
|
339
|
+
result.written = True
|
|
340
|
+
remember({"event": "created", "name": best.name, "score": round(best.quality.score, 3),
|
|
341
|
+
"rounds": result.rounds, "provider": llm.name, "model": llm.model, "task": task[:200],
|
|
342
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
|
343
|
+
return result
|
|
344
|
+
finally:
|
|
345
|
+
if work_dir is None:
|
|
346
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
agentskill_kit/i18n.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Interface strings. Rule messages and docs stay in English; the CLI chrome is translated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import locale
|
|
6
|
+
import os
|
|
7
|
+
from typing import Dict
|
|
8
|
+
|
|
9
|
+
_LANG = "en"
|
|
10
|
+
|
|
11
|
+
STRINGS: Dict[str, Dict[str, str]] = {
|
|
12
|
+
"en": {
|
|
13
|
+
"no_skills": "No skills found in {path}",
|
|
14
|
+
"lint_title": "Lint",
|
|
15
|
+
"scan_title": "Security scan",
|
|
16
|
+
"test_title": "Tests",
|
|
17
|
+
"score": "score",
|
|
18
|
+
"no_issues": "no issues",
|
|
19
|
+
"no_findings": "no findings",
|
|
20
|
+
"summary_lint": "{n} skill(s) checked: {e} error(s), {w} warning(s)",
|
|
21
|
+
"summary_scan": "{n} skill(s) scanned: {h} high, {m} medium, {l} low",
|
|
22
|
+
"summary_test": "{p}/{t} test case(s) passed across {n} skill(s)",
|
|
23
|
+
"no_tests": "no tests (instruction-only skill)",
|
|
24
|
+
"created": "Created {path}",
|
|
25
|
+
"exists": "{path} already exists (use --force to overwrite)",
|
|
26
|
+
"forge_start": "Forging a skill with {provider} ({model})",
|
|
27
|
+
"forge_round": "Round {n}/{total}",
|
|
28
|
+
"forge_done": "Skill written to {path} (score {score})",
|
|
29
|
+
"forge_below": "Best score {score} is below the threshold {threshold}; saved anyway because of --force",
|
|
30
|
+
"forge_rejected": "Best score {score} is below the threshold {threshold}; nothing written (lower --threshold or use --force)",
|
|
31
|
+
"exported": "Exported {name} to {target}",
|
|
32
|
+
"installed": "Installed {name} ({target})",
|
|
33
|
+
"uninstalled": "Removed {name}",
|
|
34
|
+
"packed": "Packed {path}",
|
|
35
|
+
"no_results": "No matching skills",
|
|
36
|
+
"mine_none": "No repeated workflows found (try --min-count 2)",
|
|
37
|
+
"mine_found": "Found {n} repeated workflow(s)",
|
|
38
|
+
"serving": "Serving on {url} (Ctrl+C to stop)",
|
|
39
|
+
"doctor_title": "Environment check",
|
|
40
|
+
"gate_failed": "Gate failed: {reason}",
|
|
41
|
+
"published": "Ready to publish {name}",
|
|
42
|
+
"provider_missing": "No LLM provider is configured. See: askit providers",
|
|
43
|
+
"threshold_note": "Quality gate: {threshold}",
|
|
44
|
+
},
|
|
45
|
+
"tr": {
|
|
46
|
+
"no_skills": "{path} içinde skill bulunamadı",
|
|
47
|
+
"lint_title": "Lint",
|
|
48
|
+
"scan_title": "Güvenlik taraması",
|
|
49
|
+
"test_title": "Testler",
|
|
50
|
+
"score": "puan",
|
|
51
|
+
"no_issues": "sorun yok",
|
|
52
|
+
"no_findings": "bulgu yok",
|
|
53
|
+
"summary_lint": "{n} skill kontrol edildi: {e} hata, {w} uyarı",
|
|
54
|
+
"summary_scan": "{n} skill tarandı: {h} yüksek, {m} orta, {l} düşük",
|
|
55
|
+
"summary_test": "{n} skill içinde {p}/{t} test senaryosu başarılı",
|
|
56
|
+
"no_tests": "test yok (yalnızca talimat içeren skill)",
|
|
57
|
+
"created": "{path} oluşturuldu",
|
|
58
|
+
"exists": "{path} zaten var (üzerine yazmak için --force kullanın)",
|
|
59
|
+
"forge_start": "{provider} ({model}) ile skill üretiliyor",
|
|
60
|
+
"forge_round": "Tur {n}/{total}",
|
|
61
|
+
"forge_done": "Skill {path} konumuna yazıldı (puan {score})",
|
|
62
|
+
"forge_below": "En iyi puan {score}, eşik {threshold} altında; --force nedeniyle yine de kaydedildi",
|
|
63
|
+
"forge_rejected": "En iyi puan {score}, eşik {threshold} altında; hiçbir şey yazılmadı (--threshold değerini düşürün veya --force kullanın)",
|
|
64
|
+
"exported": "{name}, {target} hedefine aktarıldı",
|
|
65
|
+
"installed": "{name} kuruldu ({target})",
|
|
66
|
+
"uninstalled": "{name} kaldırıldı",
|
|
67
|
+
"packed": "{path} paketlendi",
|
|
68
|
+
"no_results": "Eşleşen skill yok",
|
|
69
|
+
"mine_none": "Tekrar eden iş akışı bulunamadı (--min-count 2 deneyin)",
|
|
70
|
+
"mine_found": "{n} tekrar eden iş akışı bulundu",
|
|
71
|
+
"serving": "{url} adresinde çalışıyor (durdurmak için Ctrl+C)",
|
|
72
|
+
"doctor_title": "Ortam kontrolü",
|
|
73
|
+
"gate_failed": "Kapı geçilemedi: {reason}",
|
|
74
|
+
"published": "{name} yayına hazır",
|
|
75
|
+
"provider_missing": "Yapılandırılmış bir LLM sağlayıcı yok. Bkz: askit providers",
|
|
76
|
+
"threshold_note": "Kalite eşiği: {threshold}",
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def set_lang(lang: str) -> None:
|
|
82
|
+
global _LANG
|
|
83
|
+
lang = (lang or "en").lower()[:2]
|
|
84
|
+
_LANG = lang if lang in STRINGS else "en"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_lang() -> str:
|
|
88
|
+
return _LANG
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect_lang() -> str:
|
|
92
|
+
env = os.environ.get("ASKIT_LANG")
|
|
93
|
+
if env:
|
|
94
|
+
return env.lower()[:2]
|
|
95
|
+
try:
|
|
96
|
+
loc = locale.getlocale()[0] or os.environ.get("LANG", "")
|
|
97
|
+
except Exception:
|
|
98
|
+
loc = os.environ.get("LANG", "")
|
|
99
|
+
return "tr" if str(loc).lower().startswith("tr") else "en"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def t(key: str, **kwargs) -> str:
|
|
103
|
+
text = STRINGS.get(_LANG, {}).get(key) or STRINGS["en"].get(key) or key
|
|
104
|
+
try:
|
|
105
|
+
return text.format(**kwargs)
|
|
106
|
+
except (KeyError, IndexError):
|
|
107
|
+
return text
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Install skills from a registry, a GitHub repository or a local folder.
|
|
2
|
+
|
|
3
|
+
Every download is verified against the checksums in the registry index (when
|
|
4
|
+
there is one) and is linted and scanned before it is placed anywhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import tempfile
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import registry
|
|
21
|
+
from .config import DEFAULT_REGISTRY, get_setting, ledger_path
|
|
22
|
+
from .export import ExportError, export_skill, remove_skill
|
|
23
|
+
from .lint import lint_skill
|
|
24
|
+
from .scan import scan_skill
|
|
25
|
+
from .skillfile import SkillError, load_skill
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InstallError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_rel(rel: str) -> bool:
|
|
33
|
+
p = Path(rel)
|
|
34
|
+
return bool(rel) and not p.is_absolute() and ".." not in p.parts and not re.match(r"^[A-Za-z]:", rel)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_ledger() -> Dict[str, Any]:
|
|
38
|
+
p = ledger_path()
|
|
39
|
+
if not p.exists():
|
|
40
|
+
return {"installed": []}
|
|
41
|
+
try:
|
|
42
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
43
|
+
data.setdefault("installed", [])
|
|
44
|
+
return data
|
|
45
|
+
except (OSError, ValueError):
|
|
46
|
+
return {"installed": []}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save_ledger(data: Dict[str, Any]) -> None:
|
|
50
|
+
ledger_path().write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _github_get(url: str) -> Any:
|
|
54
|
+
headers = {"User-Agent": registry.USER_AGENT, "Accept": "application/vnd.github+json"}
|
|
55
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
56
|
+
if token:
|
|
57
|
+
headers["Authorization"] = "Bearer " + token
|
|
58
|
+
req = urllib.request.Request(url, headers=headers)
|
|
59
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
60
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_github_ref(spec: str) -> Tuple[str, str, str, str]:
|
|
64
|
+
"""github:owner/repo/path/to/skill@ref -> (owner, repo, path, ref)."""
|
|
65
|
+
body = spec[len("github:"):] if spec.startswith("github:") else spec
|
|
66
|
+
body = re.sub(r"^https?://github\.com/", "", body)
|
|
67
|
+
ref = ""
|
|
68
|
+
if "@" in body:
|
|
69
|
+
body, ref = body.rsplit("@", 1)
|
|
70
|
+
m = re.match(r"^([\w.-]+)/([\w.-]+)(?:/(?:tree/[^/]+/)?(.*))?$", body)
|
|
71
|
+
if not m:
|
|
72
|
+
raise InstallError("expected github:owner/repo[/path][@ref], got %r" % spec)
|
|
73
|
+
return m.group(1), m.group(2), (m.group(3) or "").strip("/"), ref
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _download_github(spec: str, dest: Path) -> str:
|
|
77
|
+
owner, repo, path, ref = parse_github_ref(spec)
|
|
78
|
+
api = "https://api.github.com/repos/%s/%s/contents/%s" % (owner, repo, urllib.parse.quote(path))
|
|
79
|
+
if ref:
|
|
80
|
+
api += "?ref=" + urllib.parse.quote(ref)
|
|
81
|
+
|
|
82
|
+
def walk(url: str, target: Path, depth: int = 0) -> None:
|
|
83
|
+
if depth > 5:
|
|
84
|
+
return
|
|
85
|
+
data = _github_get(url)
|
|
86
|
+
if isinstance(data, dict):
|
|
87
|
+
data = [data]
|
|
88
|
+
for item in data:
|
|
89
|
+
name = item.get("name", "")
|
|
90
|
+
if not _safe_rel(name):
|
|
91
|
+
continue
|
|
92
|
+
if item.get("type") == "dir":
|
|
93
|
+
walk(item["url"], target / name, depth + 1)
|
|
94
|
+
elif item.get("type") == "file" and item.get("download_url"):
|
|
95
|
+
if item.get("size", 0) > 2_000_000:
|
|
96
|
+
continue
|
|
97
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
(target / name).write_bytes(registry.fetch_bytes(item["download_url"]))
|
|
99
|
+
|
|
100
|
+
walk(api, dest)
|
|
101
|
+
return "github:%s/%s/%s" % (owner, repo, path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _download_registry(entry: Dict[str, Any], index: Dict[str, Any], source: str, dest: Path) -> None:
|
|
105
|
+
base = index.get("base_url") or ""
|
|
106
|
+
is_remote = bool(re.match(r"^[a-z][a-z0-9+.-]*://", source, re.I))
|
|
107
|
+
local_root = None if is_remote else Path(source).expanduser().resolve().parent.parent
|
|
108
|
+
for f in entry.get("files", []):
|
|
109
|
+
rel = f["path"]
|
|
110
|
+
if not _safe_rel(rel) or not _safe_rel(entry["path"]):
|
|
111
|
+
raise InstallError("registry lists an unsafe file path: %s" % rel)
|
|
112
|
+
candidate = (local_root / entry["path"] / rel) if local_root else None
|
|
113
|
+
if candidate is not None and candidate.is_file():
|
|
114
|
+
data = candidate.read_bytes()
|
|
115
|
+
elif base.startswith("http"):
|
|
116
|
+
data = registry.fetch_bytes(base.rstrip("/") + "/" + entry["path"].strip("/") + "/" + rel)
|
|
117
|
+
else:
|
|
118
|
+
raise InstallError("cannot locate %s (registry has no usable base_url)" % rel)
|
|
119
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
120
|
+
if f.get("sha256") and digest != f["sha256"]:
|
|
121
|
+
raise InstallError("checksum mismatch for %s (expected %s, got %s)" % (rel, f["sha256"][:12], digest[:12]))
|
|
122
|
+
out = dest / rel
|
|
123
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
out.write_bytes(data)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def vet(path: Path) -> Tuple[List[str], List[str]]:
|
|
128
|
+
"""Return (blocking_reasons, warnings) for a downloaded skill."""
|
|
129
|
+
blocking: List[str] = []
|
|
130
|
+
notes: List[str] = []
|
|
131
|
+
lint = lint_skill(path)
|
|
132
|
+
if lint.errors:
|
|
133
|
+
blocking.append("lint errors: " + "; ".join("[%s] %s" % (i.code, i.message) for i in lint.errors[:3]))
|
|
134
|
+
scan = scan_skill(path)
|
|
135
|
+
if scan.count("high"):
|
|
136
|
+
blocking.append("security scan found %d high severity issue(s): %s" % (
|
|
137
|
+
scan.count("high"), ", ".join(sorted({f.rule for f in scan.findings if f.severity == "high"}))))
|
|
138
|
+
if scan.count("medium"):
|
|
139
|
+
notes.append("%d medium severity finding(s); run: askit scan %s" % (scan.count("medium"), path.name))
|
|
140
|
+
return blocking, notes
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install(source: str, target: str = "claude", base: Optional[Path] = None, global_: bool = False,
|
|
144
|
+
registry_url: Optional[str] = None, force: bool = False, dry_run: bool = False) -> Dict[str, Any]:
|
|
145
|
+
"""Install a skill. `source` is a registry name, a local path or github:owner/repo/path."""
|
|
146
|
+
tmp = Path(tempfile.mkdtemp(prefix="askit-install-"))
|
|
147
|
+
try:
|
|
148
|
+
origin = ""
|
|
149
|
+
version = ""
|
|
150
|
+
if source.startswith("github:") or source.startswith("https://github.com/"):
|
|
151
|
+
staged = tmp / "skill"
|
|
152
|
+
origin = _download_github(source, staged)
|
|
153
|
+
if not (staged / "SKILL.md").exists():
|
|
154
|
+
subs = [d for d in staged.iterdir() if d.is_dir() and (d / "SKILL.md").exists()] if staged.exists() else []
|
|
155
|
+
if len(subs) == 1:
|
|
156
|
+
staged = subs[0]
|
|
157
|
+
else:
|
|
158
|
+
raise InstallError("no SKILL.md found at %s" % source)
|
|
159
|
+
elif Path(source).expanduser().exists():
|
|
160
|
+
src = Path(source).expanduser().resolve()
|
|
161
|
+
staged = tmp / src.name
|
|
162
|
+
shutil.copytree(src, staged, ignore=shutil.ignore_patterns("__pycache__", ".git", "*.pyc"))
|
|
163
|
+
origin = "local:%s" % src
|
|
164
|
+
else:
|
|
165
|
+
reg = registry_url or get_setting("registry") or DEFAULT_REGISTRY
|
|
166
|
+
index = registry.load_index(reg)
|
|
167
|
+
entry = registry.find(index, source)
|
|
168
|
+
if not entry:
|
|
169
|
+
close = [s["name"] for s in registry.search(index, source, limit=3)]
|
|
170
|
+
hint = (" Did you mean: %s?" % ", ".join(close)) if close else ""
|
|
171
|
+
raise InstallError("skill %r not found in %s.%s" % (source, reg, hint))
|
|
172
|
+
staged = tmp / entry["name"]
|
|
173
|
+
_download_registry(entry, index, reg, staged)
|
|
174
|
+
origin = "registry:%s" % reg
|
|
175
|
+
version = entry.get("version", "")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
skill = load_skill(staged)
|
|
179
|
+
except SkillError as exc:
|
|
180
|
+
raise InstallError(str(exc))
|
|
181
|
+
blocking, notes = vet(staged)
|
|
182
|
+
if blocking and not force:
|
|
183
|
+
raise InstallError("refusing to install %s: %s (use --force to override)" % (skill.name, " | ".join(blocking)))
|
|
184
|
+
if blocking:
|
|
185
|
+
notes.append("installed despite: " + " | ".join(blocking))
|
|
186
|
+
actions = export_skill(staged, target, base, global_, dry_run=dry_run)
|
|
187
|
+
if not dry_run:
|
|
188
|
+
ledger = load_ledger()
|
|
189
|
+
ledger["installed"] = [e for e in ledger["installed"]
|
|
190
|
+
if not (e["name"] == skill.name and e["target"] == target and e.get("global") == global_
|
|
191
|
+
and e.get("base") == str(base or Path.cwd()))]
|
|
192
|
+
ledger["installed"].append({"name": skill.name, "version": version or skill.version, "source": origin,
|
|
193
|
+
"target": target, "global": global_, "base": str(base or Path.cwd())})
|
|
194
|
+
save_ledger(ledger)
|
|
195
|
+
return {"name": skill.name, "version": version or skill.version, "target": target, "actions": actions,
|
|
196
|
+
"notes": notes, "source": origin}
|
|
197
|
+
except (ExportError, OSError, ValueError) as exc:
|
|
198
|
+
raise InstallError(str(exc))
|
|
199
|
+
finally:
|
|
200
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def uninstall(name: str, target: str = "claude", base: Optional[Path] = None, global_: bool = False) -> List[str]:
|
|
204
|
+
try:
|
|
205
|
+
done = remove_skill(name, target, base, global_)
|
|
206
|
+
except ExportError as exc:
|
|
207
|
+
raise InstallError(str(exc))
|
|
208
|
+
ledger = load_ledger()
|
|
209
|
+
ledger["installed"] = [e for e in ledger["installed"] if not (
|
|
210
|
+
e["name"] == name and e["target"] == target and e.get("global") == global_)]
|
|
211
|
+
save_ledger(ledger)
|
|
212
|
+
return done
|