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