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/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Paths, user configuration and a tiny .env loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
DEFAULT_REPO = "Lethe044/agentskill-kit"
|
|
11
|
+
DEFAULT_BRANCH = "main"
|
|
12
|
+
RAW_BASE = "https://raw.githubusercontent.com/%s/%s/" % (DEFAULT_REPO, DEFAULT_BRANCH)
|
|
13
|
+
DEFAULT_REGISTRY = RAW_BASE + "registry/index.json"
|
|
14
|
+
DEFAULT_THRESHOLD = 0.8
|
|
15
|
+
|
|
16
|
+
CONFIG_KEYS = {
|
|
17
|
+
"provider": "Default LLM provider (ollama, groq, gemini, openrouter, ...)",
|
|
18
|
+
"model": "Default model name for the provider",
|
|
19
|
+
"base_url": "Override the provider base URL",
|
|
20
|
+
"lang": "Interface language (en or tr)",
|
|
21
|
+
"registry": "Registry index URL or local path",
|
|
22
|
+
"threshold": "Minimum quality score (0-1) to accept a forged skill",
|
|
23
|
+
"max_rounds": "Maximum refinement rounds for forge",
|
|
24
|
+
"output_dir": "Default directory for forged skills",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def home_dir() -> Path:
|
|
29
|
+
"""Directory for config, memory and the install ledger."""
|
|
30
|
+
env = os.environ.get("ASKIT_HOME")
|
|
31
|
+
p = Path(env).expanduser() if env else Path.home() / ".agentskill-kit"
|
|
32
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return p
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def config_path() -> Path:
|
|
37
|
+
return home_dir() / "config.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config() -> Dict[str, Any]:
|
|
41
|
+
p = config_path()
|
|
42
|
+
if not p.exists():
|
|
43
|
+
return {}
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
46
|
+
return data if isinstance(data, dict) else {}
|
|
47
|
+
except (OSError, ValueError):
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_config(data: Dict[str, Any]) -> None:
|
|
52
|
+
config_path().write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_setting(key: str, default: Any = None) -> Any:
|
|
56
|
+
env = os.environ.get("ASKIT_" + key.upper())
|
|
57
|
+
if env:
|
|
58
|
+
return env
|
|
59
|
+
return load_config().get(key, default)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_setting(key: str, value: Any) -> None:
|
|
63
|
+
if key not in CONFIG_KEYS:
|
|
64
|
+
raise KeyError(key)
|
|
65
|
+
cfg = load_config()
|
|
66
|
+
if value is None or value == "":
|
|
67
|
+
cfg.pop(key, None)
|
|
68
|
+
else:
|
|
69
|
+
cfg[key] = value
|
|
70
|
+
save_config(cfg)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def memory_path() -> Path:
|
|
74
|
+
return home_dir() / "memory.jsonl"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ledger_path() -> Path:
|
|
78
|
+
return home_dir() / "installed.json"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_dotenv(path: Optional[Path] = None) -> int:
|
|
82
|
+
"""Load KEY=VALUE pairs from .env into os.environ without overriding."""
|
|
83
|
+
p = path or Path.cwd() / ".env"
|
|
84
|
+
if not p.is_file():
|
|
85
|
+
return 0
|
|
86
|
+
count = 0
|
|
87
|
+
try:
|
|
88
|
+
for line in p.read_text(encoding="utf-8").splitlines():
|
|
89
|
+
line = line.strip()
|
|
90
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
91
|
+
continue
|
|
92
|
+
if line.startswith("export "):
|
|
93
|
+
line = line[7:]
|
|
94
|
+
key, _, val = line.partition("=")
|
|
95
|
+
key = key.strip()
|
|
96
|
+
val = val.strip()
|
|
97
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
|
|
98
|
+
val = val[1:-1]
|
|
99
|
+
if key and key not in os.environ:
|
|
100
|
+
os.environ[key] = val
|
|
101
|
+
count += 1
|
|
102
|
+
except (OSError, UnicodeDecodeError):
|
|
103
|
+
return count
|
|
104
|
+
return count
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Behaviour evals: does a model follow a skill better than it does without it?
|
|
2
|
+
|
|
3
|
+
A skill may ship ``evals/evals.json``:
|
|
4
|
+
|
|
5
|
+
{"cases": [{"name": "...", "prompt": "...", "must_include": ["..."],
|
|
6
|
+
"must_not_include": ["..."], "regex": ["..."]}]}
|
|
7
|
+
|
|
8
|
+
The prompt is sent to a model twice, once with the skill instructions in the
|
|
9
|
+
system message and once without (``--baseline``). Checks are plain string
|
|
10
|
+
and regex assertions, so results are reproducible and need no judge model.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from .providers import LLM
|
|
22
|
+
from .skillfile import SkillError, load_skill
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class EvalCase:
|
|
27
|
+
name: str
|
|
28
|
+
passed: bool
|
|
29
|
+
detail: str = ""
|
|
30
|
+
baseline_passed: Optional[bool] = None
|
|
31
|
+
output: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class EvalReport:
|
|
36
|
+
skill: str
|
|
37
|
+
cases: List[EvalCase] = field(default_factory=list)
|
|
38
|
+
has_evals: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def passed(self) -> int:
|
|
42
|
+
return sum(1 for c in self.cases if c.passed)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def baseline_passed(self) -> Optional[int]:
|
|
46
|
+
vals = [c.baseline_passed for c in self.cases if c.baseline_passed is not None]
|
|
47
|
+
return sum(1 for v in vals if v) if vals else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def skill_system_prompt(name: str, description: str, body: str) -> str:
|
|
51
|
+
return ("You are a coding assistant. You have access to the following skill. Apply it when the request "
|
|
52
|
+
"matches its description.\n\nSkill name: %s\nDescription: %s\n\nInstructions:\n%s" % (name, description, body))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_output(case: Dict[str, Any], output: str) -> str:
|
|
56
|
+
low = output.lower()
|
|
57
|
+
for s in case.get("must_include", []) or []:
|
|
58
|
+
if str(s).lower() not in low:
|
|
59
|
+
return "missing %r" % s
|
|
60
|
+
for s in case.get("must_not_include", []) or []:
|
|
61
|
+
if str(s).lower() in low:
|
|
62
|
+
return "contains forbidden %r" % s
|
|
63
|
+
for rx in case.get("regex", []) or []:
|
|
64
|
+
if not re.search(rx, output, re.M | re.I):
|
|
65
|
+
return "does not match /%s/" % rx
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_evals(skill_dir: Any, llm: LLM, baseline: bool = False, limit: int = 0) -> EvalReport:
|
|
70
|
+
skill = load_skill(skill_dir)
|
|
71
|
+
path = skill.path / "evals" / "evals.json"
|
|
72
|
+
report = EvalReport(skill=skill.name)
|
|
73
|
+
if not path.exists():
|
|
74
|
+
return report
|
|
75
|
+
report.has_evals = True
|
|
76
|
+
try:
|
|
77
|
+
spec = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
except ValueError as exc:
|
|
79
|
+
raise SkillError("evals/evals.json is not valid JSON: %s" % exc)
|
|
80
|
+
cases = spec.get("cases", [])
|
|
81
|
+
if limit:
|
|
82
|
+
cases = cases[:limit]
|
|
83
|
+
system = skill_system_prompt(skill.name, skill.description, skill.body)
|
|
84
|
+
for idx, case in enumerate(cases):
|
|
85
|
+
name = str(case.get("name") or "case %d" % (idx + 1))
|
|
86
|
+
prompt = str(case.get("prompt", ""))
|
|
87
|
+
out = llm.chat([{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
|
88
|
+
temperature=0.0, max_tokens=1500).content
|
|
89
|
+
reason = check_output(case, out)
|
|
90
|
+
item = EvalCase(name, not reason, reason, output=out)
|
|
91
|
+
if baseline:
|
|
92
|
+
base_out = llm.chat([{"role": "user", "content": prompt}], temperature=0.0, max_tokens=1500).content
|
|
93
|
+
item.baseline_passed = not check_output(case, base_out)
|
|
94
|
+
report.cases.append(item)
|
|
95
|
+
return report
|
agentskill_kit/export.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Install a skill into the format used by different coding agents.
|
|
2
|
+
|
|
3
|
+
Skill directories (Claude Code, Hermes) are copied as they are. Agents that
|
|
4
|
+
use rule files or a single instructions file (Cursor, Windsurf, Copilot,
|
|
5
|
+
Codex, Gemini CLI) get the skill converted: frontmatter is rewritten and the
|
|
6
|
+
instructions are inlined. Managed blocks are marked so re-exporting updates
|
|
7
|
+
them instead of duplicating them.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from .skillfile import Skill, SkillError, _dump_scalar, iter_skill_files, load_skill
|
|
19
|
+
|
|
20
|
+
BUNDLE_DIR = ".agent-skills"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Target:
|
|
25
|
+
name: str
|
|
26
|
+
label: str
|
|
27
|
+
kind: str # dir | file | block
|
|
28
|
+
project: str
|
|
29
|
+
global_: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
TARGETS: Dict[str, Target] = {
|
|
33
|
+
"claude": Target("claude", "Claude Code skills", "dir", ".claude/skills", "~/.claude/skills"),
|
|
34
|
+
"hermes": Target("hermes", "Hermes Agent skills", "dir", ".hermes/skills", "~/.hermes/skills"),
|
|
35
|
+
"cursor": Target("cursor", "Cursor project rules", "file", ".cursor/rules"),
|
|
36
|
+
"windsurf": Target("windsurf", "Windsurf rules", "file", ".windsurf/rules"),
|
|
37
|
+
"copilot": Target("copilot", "GitHub Copilot instructions", "file", ".github/instructions"),
|
|
38
|
+
"codex": Target("codex", "AGENTS.md (Codex and others)", "block", "AGENTS.md", "~/.codex/AGENTS.md"),
|
|
39
|
+
"gemini": Target("gemini", "Gemini CLI GEMINI.md", "block", "GEMINI.md", "~/.gemini/GEMINI.md"),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ExportError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _begin(name: str) -> str:
|
|
48
|
+
return "<!-- agentskill-kit:begin %s -->" % name
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _end(name: str) -> str:
|
|
52
|
+
return "<!-- agentskill-kit:end %s -->" % name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extra_files(skill: Skill) -> List[Path]:
|
|
56
|
+
return [p for p in iter_skill_files(skill.path) if p.as_posix() != "SKILL.md"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _footer(skill: Skill, extras: bool) -> str:
|
|
60
|
+
if not extras:
|
|
61
|
+
return ""
|
|
62
|
+
return "\n\nBundled files for this skill are in `%s/%s/`." % (BUNDLE_DIR, skill.name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _copy_tree(skill: Skill, dest: Path) -> None:
|
|
66
|
+
if dest.exists():
|
|
67
|
+
shutil.rmtree(dest)
|
|
68
|
+
shutil.copytree(skill.path, dest, ignore=shutil.ignore_patterns("__pycache__", ".git", "*.pyc"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_rule(skill: Skill, target: str, extras: bool) -> str:
|
|
72
|
+
body = skill.body.strip() + _footer(skill, extras)
|
|
73
|
+
desc = _dump_scalar(skill.description)
|
|
74
|
+
if target == "cursor":
|
|
75
|
+
return "---\ndescription: %s\nglobs:\nalwaysApply: false\n---\n\n%s\n" % (desc, body)
|
|
76
|
+
if target == "windsurf":
|
|
77
|
+
return "---\ntrigger: model_decision\ndescription: %s\n---\n\n%s\n" % (desc, body)
|
|
78
|
+
if target == "copilot":
|
|
79
|
+
return '---\napplyTo: "**"\n---\n\n# %s\n\n%s\n\n%s\n' % (skill.name, skill.description, body)
|
|
80
|
+
raise ExportError("no rule renderer for %s" % target)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_block(skill: Skill, extras: bool) -> str:
|
|
84
|
+
body = skill.body.strip() + _footer(skill, extras)
|
|
85
|
+
return "%s\n## Skill: %s\n\n%s\n\n%s\n%s\n" % (_begin(skill.name), skill.name, skill.description, body,
|
|
86
|
+
_end(skill.name))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _upsert_block(path: Path, skill: Skill, block: str) -> str:
|
|
90
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
91
|
+
pattern = re.compile(re.escape(_begin(skill.name)) + r".*?" + re.escape(_end(skill.name)) + r"\n?", re.S)
|
|
92
|
+
if pattern.search(text):
|
|
93
|
+
new = pattern.sub(lambda _m: block, text)
|
|
94
|
+
action = "updated"
|
|
95
|
+
else:
|
|
96
|
+
sep = "" if not text or text.endswith("\n\n") else ("\n" if text.endswith("\n") else "\n\n")
|
|
97
|
+
new = text + sep + block
|
|
98
|
+
action = "added"
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
path.write_text(new, encoding="utf-8")
|
|
101
|
+
return action
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def destination(target: str, base: Optional[Path] = None, global_: bool = False) -> Path:
|
|
105
|
+
if target not in TARGETS:
|
|
106
|
+
raise ExportError("unknown target %r. Choose from: %s" % (target, ", ".join(TARGETS)))
|
|
107
|
+
t = TARGETS[target]
|
|
108
|
+
if global_:
|
|
109
|
+
if not t.global_:
|
|
110
|
+
raise ExportError("%s has no global location; export it into a project instead" % t.label)
|
|
111
|
+
return Path(t.global_).expanduser()
|
|
112
|
+
return (base or Path.cwd()) / t.project
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def export_skill(skill_path: Path, target: str, base: Optional[Path] = None, global_: bool = False,
|
|
116
|
+
dry_run: bool = False) -> List[str]:
|
|
117
|
+
"""Export one skill. Returns a list of human readable actions."""
|
|
118
|
+
try:
|
|
119
|
+
skill = load_skill(skill_path)
|
|
120
|
+
except SkillError as exc:
|
|
121
|
+
raise ExportError(str(exc))
|
|
122
|
+
t = TARGETS.get(target)
|
|
123
|
+
if t is None:
|
|
124
|
+
raise ExportError("unknown target %r. Choose from: %s" % (target, ", ".join(TARGETS)))
|
|
125
|
+
dest = destination(target, base, global_)
|
|
126
|
+
extras = bool(_extra_files(skill))
|
|
127
|
+
actions: List[str] = []
|
|
128
|
+
|
|
129
|
+
if t.kind == "dir":
|
|
130
|
+
out = dest / skill.name
|
|
131
|
+
actions.append("copy %s -> %s" % (skill.path, out))
|
|
132
|
+
if not dry_run:
|
|
133
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
_copy_tree(skill, out)
|
|
135
|
+
elif t.kind == "file":
|
|
136
|
+
ext = ".mdc" if target == "cursor" else (".instructions.md" if target == "copilot" else ".md")
|
|
137
|
+
out = dest / (skill.name + ext)
|
|
138
|
+
actions.append("write %s" % out)
|
|
139
|
+
if extras:
|
|
140
|
+
bundle = (base or Path.cwd()) / BUNDLE_DIR / skill.name
|
|
141
|
+
actions.append("copy bundled files -> %s" % bundle)
|
|
142
|
+
if not dry_run:
|
|
143
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
out.write_text(render_rule(skill, target, extras), encoding="utf-8")
|
|
145
|
+
if extras:
|
|
146
|
+
_copy_tree(skill, (base or Path.cwd()) / BUNDLE_DIR / skill.name)
|
|
147
|
+
else:
|
|
148
|
+
actions.append("%s block for %s in %s" % ("write", skill.name, dest))
|
|
149
|
+
if extras:
|
|
150
|
+
bundle = (base or Path.cwd()) / BUNDLE_DIR / skill.name
|
|
151
|
+
actions.append("copy bundled files -> %s" % bundle)
|
|
152
|
+
if not dry_run:
|
|
153
|
+
action = _upsert_block(dest, skill, render_block(skill, extras))
|
|
154
|
+
actions[0] = "%s block for %s in %s" % (action, skill.name, dest)
|
|
155
|
+
if extras:
|
|
156
|
+
_copy_tree(skill, (base or Path.cwd()) / BUNDLE_DIR / skill.name)
|
|
157
|
+
return actions
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def remove_skill(name: str, target: str, base: Optional[Path] = None, global_: bool = False) -> List[str]:
|
|
161
|
+
t = TARGETS.get(target)
|
|
162
|
+
if t is None:
|
|
163
|
+
raise ExportError("unknown target %r" % target)
|
|
164
|
+
dest = destination(target, base, global_)
|
|
165
|
+
done: List[str] = []
|
|
166
|
+
if t.kind == "dir":
|
|
167
|
+
out = dest / name
|
|
168
|
+
if out.exists():
|
|
169
|
+
shutil.rmtree(out)
|
|
170
|
+
done.append("removed %s" % out)
|
|
171
|
+
elif t.kind == "file":
|
|
172
|
+
for ext in (".mdc", ".instructions.md", ".md"):
|
|
173
|
+
out = dest / (name + ext)
|
|
174
|
+
if out.exists():
|
|
175
|
+
out.unlink()
|
|
176
|
+
done.append("removed %s" % out)
|
|
177
|
+
else:
|
|
178
|
+
if dest.exists():
|
|
179
|
+
text = dest.read_text(encoding="utf-8")
|
|
180
|
+
pat = re.compile(r"\n?" + re.escape(_begin(name)) + r".*?" + re.escape(_end(name)) + r"\n?", re.S)
|
|
181
|
+
if pat.search(text):
|
|
182
|
+
dest.write_text(pat.sub("\n", text).lstrip("\n"), encoding="utf-8")
|
|
183
|
+
done.append("removed block %s from %s" % (name, dest))
|
|
184
|
+
bundle = (base or Path.cwd()) / BUNDLE_DIR / name
|
|
185
|
+
if bundle.exists():
|
|
186
|
+
shutil.rmtree(bundle)
|
|
187
|
+
done.append("removed %s" % bundle)
|
|
188
|
+
return done
|