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,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