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/runner.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Run the tests that ship with a skill.
|
|
2
|
+
|
|
3
|
+
Two formats are supported and can be combined:
|
|
4
|
+
|
|
5
|
+
* ``tests/cases.json``: declarative command line cases for a script.
|
|
6
|
+
* ``tests/test_*.py``: standard unittest modules, run from the skill root.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from . import sandbox
|
|
18
|
+
from .scan import scan_text, SEVERITY_RANK
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class CaseResult:
|
|
23
|
+
name: str
|
|
24
|
+
kind: str
|
|
25
|
+
passed: bool
|
|
26
|
+
detail: str = ""
|
|
27
|
+
duration: float = 0.0
|
|
28
|
+
n_passed: Optional[int] = None
|
|
29
|
+
n_total: int = 1
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
if self.n_passed is None:
|
|
33
|
+
self.n_passed = 1 if self.passed else 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TestReport:
|
|
38
|
+
__test__ = False # keep pytest from collecting this class
|
|
39
|
+
|
|
40
|
+
skill: str
|
|
41
|
+
has_tests: bool = False
|
|
42
|
+
cases: List[CaseResult] = field(default_factory=list)
|
|
43
|
+
refused: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def passed(self) -> int:
|
|
47
|
+
return sum(int(c.n_passed or 0) for c in self.cases)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total(self) -> int:
|
|
51
|
+
return sum(c.n_total for c in self.cases)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def rate(self) -> float:
|
|
55
|
+
if self.refused:
|
|
56
|
+
return 0.0
|
|
57
|
+
return (self.passed / self.total) if self.total else 0.0
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
60
|
+
return {"skill": self.skill, "has_tests": self.has_tests, "passed": self.passed,
|
|
61
|
+
"total": self.total, "refused": self.refused,
|
|
62
|
+
"cases": [{"name": c.name, "kind": c.kind, "passed": c.passed, "detail": c.detail}
|
|
63
|
+
for c in self.cases]}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def has_tests(skill_dir: Path) -> bool:
|
|
67
|
+
t = skill_dir / "tests"
|
|
68
|
+
return t.is_dir() and ((t / "cases.json").exists() or any(t.glob("test_*.py")))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _safe_rel(rel: str) -> bool:
|
|
72
|
+
p = Path(rel)
|
|
73
|
+
return not p.is_absolute() and ".." not in p.parts and bool(rel)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _as_list(v: Any) -> List[str]:
|
|
77
|
+
if v is None:
|
|
78
|
+
return []
|
|
79
|
+
return [str(x) for x in v] if isinstance(v, list) else [str(v)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _subset(expected: Any, actual: Any) -> bool:
|
|
83
|
+
if isinstance(expected, dict):
|
|
84
|
+
return isinstance(actual, dict) and all(k in actual and _subset(v, actual[k]) for k, v in expected.items())
|
|
85
|
+
if isinstance(expected, list):
|
|
86
|
+
return isinstance(actual, list) and len(expected) == len(actual) and all(
|
|
87
|
+
_subset(e, a) for e, a in zip(expected, actual))
|
|
88
|
+
return expected == actual
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_case(case: Dict[str, Any], res: sandbox.RunResult) -> str:
|
|
92
|
+
"""Return an empty string when the case passes, otherwise a reason."""
|
|
93
|
+
if res.error:
|
|
94
|
+
return "could not start: " + res.error
|
|
95
|
+
if res.timed_out:
|
|
96
|
+
return "timed out"
|
|
97
|
+
want_exit = case.get("expect_exit", 0)
|
|
98
|
+
if want_exit is not None and res.exit_code != want_exit:
|
|
99
|
+
tail = (res.stderr or res.stdout).strip().splitlines()[-1:] or [""]
|
|
100
|
+
return "exit code %s, expected %s (%s)" % (res.exit_code, want_exit, tail[0][:120])
|
|
101
|
+
for s in _as_list(case.get("stdout_contains")):
|
|
102
|
+
if s not in res.stdout:
|
|
103
|
+
return "stdout does not contain %r" % s
|
|
104
|
+
for s in _as_list(case.get("stdout_not_contains")):
|
|
105
|
+
if s in res.stdout:
|
|
106
|
+
return "stdout unexpectedly contains %r" % s
|
|
107
|
+
for s in _as_list(case.get("stderr_contains")):
|
|
108
|
+
if s not in res.stderr:
|
|
109
|
+
return "stderr does not contain %r" % s
|
|
110
|
+
for rx in _as_list(case.get("stdout_regex")):
|
|
111
|
+
if not re.search(rx, res.stdout, re.M):
|
|
112
|
+
return "stdout does not match /%s/" % rx
|
|
113
|
+
if "stdout_json" in case:
|
|
114
|
+
try:
|
|
115
|
+
actual = json.loads(res.stdout)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return "stdout is not valid JSON"
|
|
118
|
+
if not _subset(case["stdout_json"], actual):
|
|
119
|
+
return "JSON output differs from expected"
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _run_cases(root: Path, spec: Dict[str, Any], timeout: float) -> List[CaseResult]:
|
|
124
|
+
"""Run declarative cases. Each case gets its own empty working directory."""
|
|
125
|
+
import shutil
|
|
126
|
+
import tempfile
|
|
127
|
+
|
|
128
|
+
entry = spec.get("entry")
|
|
129
|
+
out: List[CaseResult] = []
|
|
130
|
+
for idx, case in enumerate(spec.get("cases", [])):
|
|
131
|
+
name = str(case.get("name") or "case %d" % (idx + 1))
|
|
132
|
+
kind = str(case.get("kind") or "case")
|
|
133
|
+
script = case.get("entry") or entry
|
|
134
|
+
if not script or not _safe_rel(str(script)) or not (root / script).is_file():
|
|
135
|
+
out.append(CaseResult(name, kind, False, "entry script not found: %s" % script))
|
|
136
|
+
continue
|
|
137
|
+
bad = next((f for f in (case.get("files") or {}) if not _safe_rel(f)), None)
|
|
138
|
+
if bad:
|
|
139
|
+
out.append(CaseResult(name, kind, False, "unsafe file path in case: %s" % bad))
|
|
140
|
+
continue
|
|
141
|
+
workdir = Path(tempfile.mkdtemp(prefix="askit-case-"))
|
|
142
|
+
try:
|
|
143
|
+
for rel, content in (case.get("files") or {}).items():
|
|
144
|
+
p = workdir / rel
|
|
145
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
p.write_text(str(content), encoding="utf-8")
|
|
147
|
+
args = [str((root / script).resolve())] + [str(a) for a in case.get("args", [])]
|
|
148
|
+
res = sandbox.run_python(args, workdir, stdin=str(case.get("stdin", "")),
|
|
149
|
+
timeout=float(case.get("timeout", timeout)))
|
|
150
|
+
finally:
|
|
151
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
152
|
+
reason = _check_case(case, res)
|
|
153
|
+
out.append(CaseResult(name, kind, not reason, reason, res.duration))
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _run_unittests(root: Path, timeout: float) -> Optional[CaseResult]:
|
|
158
|
+
tests_dir = root / "tests"
|
|
159
|
+
if not any(tests_dir.glob("test_*.py")):
|
|
160
|
+
return None
|
|
161
|
+
res = sandbox.run_python(["-m", "unittest", "discover", "-s", "tests", "-p", "test_*.py"],
|
|
162
|
+
root, timeout=timeout * 3)
|
|
163
|
+
text = res.stderr + "\n" + res.stdout
|
|
164
|
+
m = re.search(r"Ran (\d+) tests?", text)
|
|
165
|
+
if not m:
|
|
166
|
+
return CaseResult("unittest", "unittest", False, "could not run: " + text.strip()[-200:], res.duration, 0, 1)
|
|
167
|
+
total = int(m.group(1))
|
|
168
|
+
failures = int(next(iter(re.findall(r"failures=(\d+)", text)), 0))
|
|
169
|
+
errors = int(next(iter(re.findall(r"errors=(\d+)", text)), 0))
|
|
170
|
+
skipped = int(next(iter(re.findall(r"skipped=(\d+)", text)), 0))
|
|
171
|
+
passed = max(0, total - failures - errors)
|
|
172
|
+
ok = failures == 0 and errors == 0
|
|
173
|
+
detail = "" if ok else "%d failure(s), %d error(s)" % (failures, errors)
|
|
174
|
+
return CaseResult("unittest (%d tests%s)" % (total, ", %d skipped" % skipped if skipped else ""),
|
|
175
|
+
"unittest", ok, detail, res.duration, passed, max(total, 1))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_skill_tests(skill_dir: Any, timeout: float = 20.0, allow_unsafe: bool = False) -> TestReport:
|
|
179
|
+
base = Path(skill_dir).resolve()
|
|
180
|
+
report = TestReport(skill=base.name)
|
|
181
|
+
if not has_tests(base):
|
|
182
|
+
return report
|
|
183
|
+
report.has_tests = True
|
|
184
|
+
|
|
185
|
+
if not allow_unsafe:
|
|
186
|
+
for py in sorted(base.rglob("*.py")):
|
|
187
|
+
if "__pycache__" in py.parts:
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
findings = scan_text(py.read_text(encoding="utf-8", errors="replace"), py.name)
|
|
191
|
+
except OSError:
|
|
192
|
+
continue
|
|
193
|
+
high = [f for f in findings if SEVERITY_RANK[f.severity] >= 3]
|
|
194
|
+
if high:
|
|
195
|
+
report.refused = "%s has high severity findings (%s); refusing to execute" % (
|
|
196
|
+
py.relative_to(base).as_posix(), ", ".join(sorted({f.rule for f in high})))
|
|
197
|
+
return report
|
|
198
|
+
|
|
199
|
+
with sandbox.scratch_copy(base) as root:
|
|
200
|
+
spec_path = root / "tests" / "cases.json"
|
|
201
|
+
if spec_path.exists():
|
|
202
|
+
try:
|
|
203
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
204
|
+
except ValueError as exc:
|
|
205
|
+
report.cases.append(CaseResult("cases.json", "spec", False, "invalid JSON: %s" % exc))
|
|
206
|
+
spec = {}
|
|
207
|
+
if isinstance(spec, dict) and spec.get("cases"):
|
|
208
|
+
report.cases.extend(_run_cases(root, spec, timeout))
|
|
209
|
+
elif isinstance(spec, dict) and not report.cases:
|
|
210
|
+
report.cases.append(CaseResult("cases.json", "spec", False, "no cases defined"))
|
|
211
|
+
ut = _run_unittests(root, timeout)
|
|
212
|
+
if ut:
|
|
213
|
+
report.cases.append(ut)
|
|
214
|
+
return report
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Run untrusted skill scripts in a throwaway working directory.
|
|
2
|
+
|
|
3
|
+
This limits accidents (runaway loops, huge output, stray writes, leaked API
|
|
4
|
+
keys in the environment). It is not a security boundary: a hostile script can
|
|
5
|
+
still do anything the current user can. The scanner refuses obviously
|
|
6
|
+
dangerous code before it is run, and you should use containers for real
|
|
7
|
+
isolation when testing skills you do not trust.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import time
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, Iterator, List, Optional
|
|
23
|
+
|
|
24
|
+
MAX_OUTPUT = 20_000
|
|
25
|
+
_PASS_ENV = ("PATH", "SYSTEMROOT", "SystemRoot", "COMSPEC", "PATHEXT", "LANG", "LC_ALL", "TZ")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RunResult:
|
|
30
|
+
exit_code: int
|
|
31
|
+
stdout: str
|
|
32
|
+
stderr: str
|
|
33
|
+
timed_out: bool = False
|
|
34
|
+
duration: float = 0.0
|
|
35
|
+
error: str = ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def clean_env(workdir: Path, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
|
39
|
+
env = {k: os.environ[k] for k in _PASS_ENV if k in os.environ}
|
|
40
|
+
env.update({
|
|
41
|
+
"HOME": str(workdir), "USERPROFILE": str(workdir),
|
|
42
|
+
"TEMP": str(workdir), "TMP": str(workdir), "TMPDIR": str(workdir),
|
|
43
|
+
"PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1", "PYTHONUTF8": "1",
|
|
44
|
+
"NO_COLOR": "1",
|
|
45
|
+
})
|
|
46
|
+
if extra:
|
|
47
|
+
env.update(extra)
|
|
48
|
+
return env
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _limit_resources(timeout: float, mem_mb: int):
|
|
52
|
+
def apply() -> None:
|
|
53
|
+
try:
|
|
54
|
+
import resource
|
|
55
|
+
os.setsid()
|
|
56
|
+
resource.setrlimit(resource.RLIMIT_CPU, (int(timeout) + 2, int(timeout) + 2))
|
|
57
|
+
resource.setrlimit(resource.RLIMIT_FSIZE, (50 * 1024 * 1024, 50 * 1024 * 1024))
|
|
58
|
+
resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256))
|
|
59
|
+
if sys.platform.startswith("linux"):
|
|
60
|
+
limit = mem_mb * 1024 * 1024
|
|
61
|
+
resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
return apply
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _trim(text: str) -> str:
|
|
68
|
+
# Windows consoles turn "\n" into "\r\n"; tests should see the same text everywhere.
|
|
69
|
+
text = text.replace("\r\n", "\n")
|
|
70
|
+
return text if len(text) <= MAX_OUTPUT else text[:MAX_OUTPUT] + "\n...[output truncated]"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_python(args: List[str], cwd: Path, stdin: str = "", timeout: float = 20.0,
|
|
74
|
+
extra_env: Optional[Dict[str, str]] = None, mem_mb: int = 768) -> RunResult:
|
|
75
|
+
"""Run `python <args>` inside cwd with a clean environment."""
|
|
76
|
+
cmd = [sys.executable, "-B"] + list(args)
|
|
77
|
+
start = time.time()
|
|
78
|
+
kwargs: Dict[str, object] = {}
|
|
79
|
+
if os.name == "nt":
|
|
80
|
+
kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
81
|
+
else:
|
|
82
|
+
kwargs["preexec_fn"] = _limit_resources(timeout, mem_mb)
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.Popen(
|
|
85
|
+
cmd, cwd=str(cwd), env=clean_env(cwd, extra_env), stdin=subprocess.PIPE,
|
|
86
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) # type: ignore[arg-type]
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
return RunResult(-1, "", "", error=str(exc))
|
|
89
|
+
try:
|
|
90
|
+
out, err = proc.communicate(stdin.encode("utf-8"), timeout=timeout)
|
|
91
|
+
timed_out = False
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
timed_out = True
|
|
94
|
+
try:
|
|
95
|
+
if os.name == "nt":
|
|
96
|
+
proc.kill()
|
|
97
|
+
else:
|
|
98
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
99
|
+
except Exception:
|
|
100
|
+
proc.kill()
|
|
101
|
+
out, err = proc.communicate()
|
|
102
|
+
return RunResult(
|
|
103
|
+
exit_code=-1 if timed_out else proc.returncode,
|
|
104
|
+
stdout=_trim(out.decode("utf-8", errors="replace")),
|
|
105
|
+
stderr=_trim(err.decode("utf-8", errors="replace")),
|
|
106
|
+
timed_out=timed_out, duration=time.time() - start)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@contextmanager
|
|
110
|
+
def scratch_copy(skill_dir: Path) -> Iterator[Path]:
|
|
111
|
+
"""Copy a skill into a temp directory so tests never touch the original."""
|
|
112
|
+
tmp = Path(tempfile.mkdtemp(prefix="askit-"))
|
|
113
|
+
target = tmp / skill_dir.name
|
|
114
|
+
try:
|
|
115
|
+
shutil.copytree(skill_dir, target, ignore=shutil.ignore_patterns("__pycache__", ".git", "*.pyc"))
|
|
116
|
+
yield target
|
|
117
|
+
finally:
|
|
118
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Create a new skill from a template."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .skillfile import dump_frontmatter, slugify
|
|
9
|
+
|
|
10
|
+
SCRIPT = '''#!/usr/bin/env python3
|
|
11
|
+
"""%(summary)s"""
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(text):
|
|
18
|
+
"""Return a result for the given text."""
|
|
19
|
+
words = text.split()
|
|
20
|
+
return {"words": len(words), "characters": len(text)}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv=None):
|
|
24
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
25
|
+
parser.add_argument("file", nargs="?", help="input file (reads stdin when omitted)")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
if args.file:
|
|
28
|
+
try:
|
|
29
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
30
|
+
text = fh.read()
|
|
31
|
+
except OSError:
|
|
32
|
+
print("file not found: %%s" %% args.file, file=sys.stderr)
|
|
33
|
+
return 2
|
|
34
|
+
else:
|
|
35
|
+
text = sys.stdin.read()
|
|
36
|
+
print(json.dumps(run(text)))
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
sys.exit(main())
|
|
42
|
+
'''
|
|
43
|
+
|
|
44
|
+
CASES = '''{
|
|
45
|
+
"entry": "scripts/main.py",
|
|
46
|
+
"cases": [
|
|
47
|
+
{"name": "counts words", "kind": "happy", "stdin": "one two three",
|
|
48
|
+
"stdout_json": {"words": 3, "characters": 13}},
|
|
49
|
+
{"name": "empty input", "kind": "edge", "stdin": "",
|
|
50
|
+
"stdout_json": {"words": 0, "characters": 0}},
|
|
51
|
+
{"name": "missing file", "kind": "error", "args": ["missing.txt"],
|
|
52
|
+
"expect_exit": 2, "stderr_contains": "file not found"}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_skill(name: str, parent: Path, description: str = "", with_script: bool = False,
|
|
59
|
+
author: str = "", tags: Optional[List[str]] = None, force: bool = False) -> Path:
|
|
60
|
+
slug = slugify(name)
|
|
61
|
+
dest = Path(parent) / slug
|
|
62
|
+
if dest.exists() and any(dest.iterdir()) and not force:
|
|
63
|
+
raise FileExistsError(str(dest))
|
|
64
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
desc = description.strip() or ("Describe what this skill does in one sentence. Use when the user asks "
|
|
66
|
+
"for the task this skill covers.")
|
|
67
|
+
meta = {"version": "0.1.0"}
|
|
68
|
+
if author:
|
|
69
|
+
meta["author"] = author
|
|
70
|
+
meta["tags"] = tags or ["general"]
|
|
71
|
+
fm = dump_frontmatter({"name": slug, "description": desc, "license": "MIT", "metadata": meta})
|
|
72
|
+
title = slug.replace("-", " ").capitalize()
|
|
73
|
+
lines = [fm, "# %s\n" % title,
|
|
74
|
+
"## When to use\n",
|
|
75
|
+
"- Replace this line with the situations where an agent should pick this skill.\n",
|
|
76
|
+
"## Steps\n",
|
|
77
|
+
"1. Replace this step with the first thing the agent should do.",
|
|
78
|
+
"2. Add the next step. Keep every step concrete and checkable.\n"]
|
|
79
|
+
if with_script:
|
|
80
|
+
lines += ["## Script\n", "```bash", "python scripts/main.py path/to/input.txt", "```\n",
|
|
81
|
+
"The script prints JSON with the word and character count. Replace it with your own logic.\n"]
|
|
82
|
+
lines += ["## Examples\n", "Input: describe a typical request.\n", "Output: describe the expected result.\n",
|
|
83
|
+
"## Errors\n", "- Say what the agent should do when the input is missing or invalid.\n"]
|
|
84
|
+
(dest / "SKILL.md").write_text("\n".join(lines), encoding="utf-8")
|
|
85
|
+
if with_script:
|
|
86
|
+
(dest / "scripts").mkdir(exist_ok=True)
|
|
87
|
+
(dest / "scripts" / "main.py").write_text(SCRIPT % {"summary": desc.split(".")[0] + "."}, encoding="utf-8")
|
|
88
|
+
(dest / "tests").mkdir(exist_ok=True)
|
|
89
|
+
(dest / "tests" / "cases.json").write_text(CASES, encoding="utf-8")
|
|
90
|
+
return dest
|