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