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/lint.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Structural checks for SKILL.md files. Works offline and needs no model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .skillfile import Skill, SkillError, load_skill
|
|
11
|
+
|
|
12
|
+
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
13
|
+
KNOWN_KEYS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}
|
|
14
|
+
LEGACY_KEYS = {"version", "author", "tags"}
|
|
15
|
+
RESERVED_WORDS = ("anthropic", "claude")
|
|
16
|
+
TRIGGER_RE = re.compile(
|
|
17
|
+
r"\b(use when|use this|use for|use it|when the user|when you|whenever|if the user|"
|
|
18
|
+
r"trigger|invoke|asks? (?:to|for)|needs? to|when (?:asked|working|reviewing|writing|"
|
|
19
|
+
r"creating|debugging|analy[sz]ing|you))\b", re.I)
|
|
20
|
+
FIRST_PERSON_RE = re.compile(r"^\s*(i|i'll|i can|i will|my|we|you can|you will)\b", re.I)
|
|
21
|
+
PLACEHOLDER_RE = re.compile(
|
|
22
|
+
r"\b(?:TODO|FIXME|TBD):|[\[<](?:TODO|FIXME|TBD)[\]>]|\blorem ipsum\b|<your[ -][^>]*>|\[insert [^\]]*\]"
|
|
23
|
+
r"|\bReplace this (?:line|step)\b|\bDescribe what this skill does\b", re.I)
|
|
24
|
+
REF_RE = re.compile(r"(?<![\w/.-])((?:scripts|references|assets|tests|evals)/[\w./-]+\.[A-Za-z0-9]+)")
|
|
25
|
+
LINK_RE = re.compile(r"\]\((?!https?:|mailto:|#)([^)\s#]+)")
|
|
26
|
+
|
|
27
|
+
SEVERITY_ORDER = {"error": 0, "warning": 1, "info": 2}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Issue:
|
|
32
|
+
code: str
|
|
33
|
+
severity: str
|
|
34
|
+
message: str
|
|
35
|
+
file: str = "SKILL.md"
|
|
36
|
+
line: int = 0
|
|
37
|
+
hint: str = ""
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
40
|
+
return {"code": self.code, "severity": self.severity, "message": self.message,
|
|
41
|
+
"file": self.file, "line": self.line, "hint": self.hint}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class LintResult:
|
|
46
|
+
skill: str
|
|
47
|
+
path: str
|
|
48
|
+
issues: List[Issue] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def errors(self) -> List[Issue]:
|
|
52
|
+
return [i for i in self.issues if i.severity == "error"]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def warnings(self) -> List[Issue]:
|
|
56
|
+
return [i for i in self.issues if i.severity == "warning"]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def score(self) -> int:
|
|
60
|
+
return max(0, 100 - 25 * len(self.errors) - 6 * len(self.warnings))
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
63
|
+
return {"skill": self.skill, "path": self.path, "score": self.score,
|
|
64
|
+
"errors": len(self.errors), "warnings": len(self.warnings),
|
|
65
|
+
"issues": [i.to_dict() for i in self.issues]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_line(skill: Skill, key: str) -> int:
|
|
69
|
+
for idx, ln in enumerate(skill.fm_lines):
|
|
70
|
+
if ln.startswith(key + ":"):
|
|
71
|
+
return idx + 2
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def lint_skill(target: Any) -> LintResult:
|
|
76
|
+
"""Lint a skill directory (or SKILL.md path)."""
|
|
77
|
+
try:
|
|
78
|
+
skill = load_skill(target)
|
|
79
|
+
except SkillError as exc:
|
|
80
|
+
return LintResult(skill=Path(str(target)).name, path=str(target),
|
|
81
|
+
issues=[Issue("E001", "error", str(exc), file=str(target))])
|
|
82
|
+
return lint_loaded(skill)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def lint_loaded(skill: Skill) -> LintResult:
|
|
86
|
+
res = LintResult(skill=skill.name, path=str(skill.path))
|
|
87
|
+
add = res.issues.append
|
|
88
|
+
|
|
89
|
+
if not skill.has_frontmatter:
|
|
90
|
+
add(Issue("E002", "error", "SKILL.md must start with a YAML frontmatter block between --- lines",
|
|
91
|
+
line=1, hint="Add name and description at the top of the file."))
|
|
92
|
+
return res
|
|
93
|
+
|
|
94
|
+
for line_no, msg in skill.fm_errors:
|
|
95
|
+
add(Issue("E010", "error", "frontmatter: " + msg, line=line_no))
|
|
96
|
+
for idx, ln in enumerate(skill.fm_lines):
|
|
97
|
+
if "\t" in ln:
|
|
98
|
+
add(Issue("W010", "warning", "tab character in frontmatter; use spaces", line=idx + 2))
|
|
99
|
+
|
|
100
|
+
meta = skill.meta
|
|
101
|
+
name = meta.get("name")
|
|
102
|
+
if not name or not isinstance(name, str):
|
|
103
|
+
add(Issue("E003", "error", "missing required field: name", line=1))
|
|
104
|
+
else:
|
|
105
|
+
line = _find_line(skill, "name")
|
|
106
|
+
if len(name) > 64:
|
|
107
|
+
add(Issue("E004", "error", "name is %d characters; the limit is 64" % len(name), line=line))
|
|
108
|
+
if not NAME_RE.match(name):
|
|
109
|
+
add(Issue("E005", "error",
|
|
110
|
+
"name must use lowercase letters, digits and single hyphens (got %r)" % name,
|
|
111
|
+
line=line, hint="Example: log-analyzer"))
|
|
112
|
+
if name != skill.path.name:
|
|
113
|
+
add(Issue("E006", "error",
|
|
114
|
+
"name %r does not match the directory name %r" % (name, skill.path.name),
|
|
115
|
+
line=line, hint="Rename the directory or the name field so both agree."))
|
|
116
|
+
if any(w in name for w in RESERVED_WORDS):
|
|
117
|
+
add(Issue("W011", "warning", "name contains a reserved word (%s)" % "/".join(RESERVED_WORDS),
|
|
118
|
+
line=line, hint="Some agents reject skill names that include these words."))
|
|
119
|
+
|
|
120
|
+
desc = meta.get("description")
|
|
121
|
+
dline = _find_line(skill, "description")
|
|
122
|
+
if not desc or not isinstance(desc, str):
|
|
123
|
+
add(Issue("E007", "error", "missing required field: description", line=1,
|
|
124
|
+
hint="Describe what the skill does and when an agent should use it."))
|
|
125
|
+
else:
|
|
126
|
+
if len(desc) > 1024:
|
|
127
|
+
add(Issue("E008", "error", "description is %d characters; the limit is 1024" % len(desc), line=dline))
|
|
128
|
+
if re.search(r"</?[A-Za-z][^>]*>", desc):
|
|
129
|
+
add(Issue("E009", "error", "description must not contain XML or HTML tags", line=dline))
|
|
130
|
+
if PLACEHOLDER_RE.search(desc):
|
|
131
|
+
add(Issue("W007", "warning", "description still contains template text", line=dline))
|
|
132
|
+
if len(desc) < 40:
|
|
133
|
+
add(Issue("W001", "warning", "description is very short (%d characters)" % len(desc), line=dline,
|
|
134
|
+
hint="Agents pick skills by description. Say what it does and when to use it."))
|
|
135
|
+
if not TRIGGER_RE.search(desc):
|
|
136
|
+
add(Issue("W002", "warning", "description does not say when to use the skill", line=dline,
|
|
137
|
+
hint="Add a sentence such as 'Use when the user asks to ...'."))
|
|
138
|
+
if FIRST_PERSON_RE.match(desc):
|
|
139
|
+
add(Issue("W012", "warning", "description should be written in the third person", line=dline,
|
|
140
|
+
hint="Write 'Analyzes logs ...' instead of 'I analyze logs ...'."))
|
|
141
|
+
|
|
142
|
+
comp = meta.get("compatibility")
|
|
143
|
+
if isinstance(comp, str) and len(comp) > 500:
|
|
144
|
+
add(Issue("E011", "error", "compatibility is %d characters; the limit is 500" % len(comp),
|
|
145
|
+
line=_find_line(skill, "compatibility")))
|
|
146
|
+
|
|
147
|
+
if "metadata" in meta and not isinstance(meta["metadata"], dict):
|
|
148
|
+
add(Issue("E012", "error", "metadata must be a mapping of keys to values",
|
|
149
|
+
line=_find_line(skill, "metadata")))
|
|
150
|
+
|
|
151
|
+
for key in meta:
|
|
152
|
+
if key in KNOWN_KEYS:
|
|
153
|
+
continue
|
|
154
|
+
if key in LEGACY_KEYS:
|
|
155
|
+
add(Issue("I002", "info", "top-level key %r is accepted but belongs under metadata" % key,
|
|
156
|
+
line=_find_line(skill, key)))
|
|
157
|
+
else:
|
|
158
|
+
add(Issue("I003", "info", "unknown frontmatter key %r (ignored by most agents)" % key,
|
|
159
|
+
line=_find_line(skill, key)))
|
|
160
|
+
if not meta.get("license"):
|
|
161
|
+
add(Issue("I001", "info", "no license field", line=1,
|
|
162
|
+
hint="Add license: MIT (or similar) if you plan to share the skill."))
|
|
163
|
+
|
|
164
|
+
body = skill.body
|
|
165
|
+
nonblank = [ln for ln in body.splitlines() if ln.strip()]
|
|
166
|
+
if len(nonblank) < 3:
|
|
167
|
+
add(Issue("W003", "warning", "the instructions are empty or nearly empty", line=skill.body_line))
|
|
168
|
+
total_lines = len(body.splitlines())
|
|
169
|
+
if total_lines > 500:
|
|
170
|
+
add(Issue("W004", "warning", "body has %d lines; keep SKILL.md under 500" % total_lines,
|
|
171
|
+
line=skill.body_line, hint="Move long reference material to references/*.md."))
|
|
172
|
+
if nonblank and not re.search(r"^#{1,3}\s+\S", body, re.M):
|
|
173
|
+
add(Issue("W005", "warning", "no markdown headings in the instructions", line=skill.body_line))
|
|
174
|
+
for m in PLACEHOLDER_RE.finditer(body):
|
|
175
|
+
line = skill.body_line + body.count("\n", 0, m.start())
|
|
176
|
+
add(Issue("W007", "warning", "placeholder text found: %r" % m.group(0), line=line))
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
seen = set()
|
|
180
|
+
for m in list(REF_RE.finditer(body)) + list(LINK_RE.finditer(body)):
|
|
181
|
+
ref = m.group(1).strip("`.,;:")
|
|
182
|
+
if ref in seen or not ref:
|
|
183
|
+
continue
|
|
184
|
+
seen.add(ref)
|
|
185
|
+
if not (skill.path / ref).exists():
|
|
186
|
+
line = skill.body_line + body.count("\n", 0, m.start())
|
|
187
|
+
add(Issue("W006", "warning", "referenced file does not exist: %s" % ref, line=line))
|
|
188
|
+
|
|
189
|
+
res.issues.sort(key=lambda i: (SEVERITY_ORDER.get(i.severity, 3), i.line, i.code))
|
|
190
|
+
return res
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def format_github(result: LintResult) -> List[str]:
|
|
194
|
+
out = []
|
|
195
|
+
for i in result.issues:
|
|
196
|
+
if i.severity == "info":
|
|
197
|
+
continue
|
|
198
|
+
level = "error" if i.severity == "error" else "warning"
|
|
199
|
+
path = Path(result.path) / i.file
|
|
200
|
+
out.append("::%s file=%s,line=%d::[%s] %s" % (level, path.as_posix(), max(1, i.line), i.code, i.message))
|
|
201
|
+
return out
|
agentskill_kit/mine.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Find repeated workflows in shell history and suggest skills.
|
|
2
|
+
|
|
3
|
+
Everything runs locally. Commands are normalised (paths, numbers, quoted
|
|
4
|
+
strings and hashes become placeholders) and secrets are masked before
|
|
5
|
+
anything is shown or written.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from .scan import redact
|
|
19
|
+
from .skillfile import dump_frontmatter, slugify
|
|
20
|
+
|
|
21
|
+
TRIVIAL = {
|
|
22
|
+
"cd", "ls", "ll", "la", "dir", "pwd", "clear", "cls", "exit", "logout", "history", "echo", "man", "help",
|
|
23
|
+
"which", "where", "whoami", "date", "top", "htop", "vim", "vi", "nano", "code", "type", "cat", "less",
|
|
24
|
+
"more", "head", "tail", "mkdir", "touch", "source", "export", "set", "alias", "sleep", "true", "false",
|
|
25
|
+
"pushd", "popd", "tree", "open", "start", "tldr", "z", "j",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_PATHISH = re.compile(r"[\\/]")
|
|
29
|
+
_NUM = re.compile(r"^\d+(?:[.:_-]\d+)*$")
|
|
30
|
+
_HASH = re.compile(r"^[0-9a-f]{7,40}$")
|
|
31
|
+
_FILE = re.compile(r"^[\w.-]+\.(?:py|js|ts|tsx|json|ya?ml|md|txt|csv|log|toml|sh|ini|cfg|xml|html|sql|zip|tar|gz)$", re.I)
|
|
32
|
+
_URL = re.compile(r"^[a-z]+://", re.I)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Candidate:
|
|
37
|
+
kind: str # sequence | command
|
|
38
|
+
template: List[str]
|
|
39
|
+
count: int
|
|
40
|
+
examples: List[str]
|
|
41
|
+
score: float = 0.0
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def tools(self) -> List[str]:
|
|
45
|
+
seen: List[str] = []
|
|
46
|
+
for line in self.template:
|
|
47
|
+
tool = line.split()[0] if line.split() else ""
|
|
48
|
+
if tool and tool not in seen:
|
|
49
|
+
seen.append(tool)
|
|
50
|
+
return seen
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def name(self) -> str:
|
|
54
|
+
tools = self.tools
|
|
55
|
+
if self.kind == "command" and self.template:
|
|
56
|
+
toks = [t for t in self.template[0].split() if re.match(r"^[a-z][a-z0-9-]*$", t)][:2]
|
|
57
|
+
return slugify("-".join(toks) or "command")
|
|
58
|
+
if len(tools) == 1:
|
|
59
|
+
subs: List[str] = []
|
|
60
|
+
for line in self.template:
|
|
61
|
+
parts = line.split()
|
|
62
|
+
if len(parts) > 1 and re.match(r"^[a-z][a-z0-9-]*$", parts[1]) and parts[1] not in subs:
|
|
63
|
+
subs.append(parts[1])
|
|
64
|
+
return slugify("-".join([tools[0]] + subs[:3]))
|
|
65
|
+
return slugify("-".join(tools[:3]) + "-workflow")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Reading history
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def default_history_files() -> List[Path]:
|
|
73
|
+
home = Path.home()
|
|
74
|
+
cands = [home / ".bash_history", home / ".zsh_history", home / ".local/share/fish/fish_history"]
|
|
75
|
+
appdata = os.environ.get("APPDATA")
|
|
76
|
+
if appdata:
|
|
77
|
+
cands.append(Path(appdata) / "Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt")
|
|
78
|
+
cands.append(home / ".local/share/powershell/PSReadLine/ConsoleHost_history.txt")
|
|
79
|
+
histfile = os.environ.get("HISTFILE")
|
|
80
|
+
if histfile:
|
|
81
|
+
cands.insert(0, Path(histfile).expanduser())
|
|
82
|
+
out, seen = [], set()
|
|
83
|
+
for c in cands:
|
|
84
|
+
if c.is_file() and str(c) not in seen:
|
|
85
|
+
out.append(c)
|
|
86
|
+
seen.add(str(c))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_history(text: str, hint: str = "") -> List[str]:
|
|
91
|
+
"""Parse bash, zsh, fish or PowerShell history text into a list of commands."""
|
|
92
|
+
cmds: List[str] = []
|
|
93
|
+
lines = text.splitlines()
|
|
94
|
+
is_fish = hint == "fish" or any(ln.startswith("- cmd: ") for ln in lines[:20])
|
|
95
|
+
if is_fish:
|
|
96
|
+
for ln in lines:
|
|
97
|
+
if ln.startswith("- cmd: "):
|
|
98
|
+
cmds.append(ln[7:].strip())
|
|
99
|
+
return cmds
|
|
100
|
+
buf = ""
|
|
101
|
+
for ln in lines:
|
|
102
|
+
if re.match(r"^#\d{9,}$", ln): # bash timestamps
|
|
103
|
+
continue
|
|
104
|
+
m = re.match(r"^: \d+:\d+;(.*)$", ln) # zsh extended history
|
|
105
|
+
if m:
|
|
106
|
+
ln = m.group(1)
|
|
107
|
+
if buf:
|
|
108
|
+
ln = buf + "\n" + ln
|
|
109
|
+
buf = ""
|
|
110
|
+
if ln.endswith("\\") or ln.endswith("`"):
|
|
111
|
+
buf = ln[:-1].rstrip()
|
|
112
|
+
continue
|
|
113
|
+
if ln.strip():
|
|
114
|
+
cmds.append(ln.strip())
|
|
115
|
+
if buf:
|
|
116
|
+
cmds.append(buf.strip())
|
|
117
|
+
return cmds
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_history(paths: Optional[Iterable[Path]] = None) -> List[str]:
|
|
121
|
+
files = list(paths) if paths else default_history_files()
|
|
122
|
+
cmds: List[str] = []
|
|
123
|
+
for p in files:
|
|
124
|
+
try:
|
|
125
|
+
text = Path(p).read_text(encoding="utf-8", errors="replace")
|
|
126
|
+
except OSError:
|
|
127
|
+
continue
|
|
128
|
+
hint = "fish" if "fish" in str(p).lower() else ""
|
|
129
|
+
cmds.extend(parse_history(text, hint))
|
|
130
|
+
return cmds
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Normalisation
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def normalize(cmd: str) -> str:
|
|
138
|
+
c = re.sub(r'"[^"]*"|\'[^\']*\'', "<str>", cmd.strip())
|
|
139
|
+
c = redact(c)
|
|
140
|
+
out: List[str] = []
|
|
141
|
+
for i, tok in enumerate(c.split()):
|
|
142
|
+
if tok == "<str>" or tok.startswith("<redacted"):
|
|
143
|
+
out.append("<str>" if tok == "<str>" else tok)
|
|
144
|
+
elif i == 0:
|
|
145
|
+
out.append(tok)
|
|
146
|
+
elif _URL.match(tok):
|
|
147
|
+
out.append("<url>")
|
|
148
|
+
elif tok.startswith("-"):
|
|
149
|
+
out.append(tok)
|
|
150
|
+
elif i == 1 and re.match(r"^[a-z][a-z0-9:-]*$", tok):
|
|
151
|
+
out.append(tok) # keep the subcommand (git commit, docker build)
|
|
152
|
+
elif _PATHISH.search(tok) or tok in (".", "..", "~"):
|
|
153
|
+
out.append("<path>")
|
|
154
|
+
elif _FILE.match(tok):
|
|
155
|
+
out.append("<file>")
|
|
156
|
+
elif _HASH.match(tok) and re.search(r"\d", tok):
|
|
157
|
+
out.append("<hash>")
|
|
158
|
+
elif _NUM.match(tok):
|
|
159
|
+
out.append("<n>")
|
|
160
|
+
else:
|
|
161
|
+
out.append(tok)
|
|
162
|
+
return " ".join(out)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_trivial(cmd: str) -> bool:
|
|
166
|
+
toks = cmd.split()
|
|
167
|
+
if not toks:
|
|
168
|
+
return True
|
|
169
|
+
base = Path(toks[0]).name.lower()
|
|
170
|
+
return base in TRIVIAL or toks[0].startswith("#")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Mining
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def find_candidates(commands: List[str], min_count: int = 3, max_len: int = 4, limit: int = 15) -> List[Candidate]:
|
|
178
|
+
pairs = [(c, normalize(c)) for c in commands if not _is_trivial(c)]
|
|
179
|
+
norms = [n for _, n in pairs]
|
|
180
|
+
originals = [redact(o) for o, _ in pairs]
|
|
181
|
+
|
|
182
|
+
seq_counts: Counter = Counter()
|
|
183
|
+
seq_positions: Dict[Tuple[str, ...], int] = {}
|
|
184
|
+
for length in range(2, max_len + 1):
|
|
185
|
+
for i in range(0, len(norms) - length + 1):
|
|
186
|
+
window = tuple(norms[i:i + length])
|
|
187
|
+
if len(set(window)) == 1:
|
|
188
|
+
continue
|
|
189
|
+
seq_counts[window] += 1
|
|
190
|
+
seq_positions[window] = i
|
|
191
|
+
seqs = {w: c for w, c in seq_counts.items() if c >= min_count}
|
|
192
|
+
|
|
193
|
+
maximal: Dict[Tuple[str, ...], int] = {}
|
|
194
|
+
for w, c in sorted(seqs.items(), key=lambda kv: -len(kv[0])):
|
|
195
|
+
covered = any(len(o) > len(w) and oc >= c and _contains(o, w) for o, oc in maximal.items())
|
|
196
|
+
if not covered:
|
|
197
|
+
maximal[w] = c
|
|
198
|
+
|
|
199
|
+
# A workflow repeated back to back shows up as several rotations of itself.
|
|
200
|
+
# Keep one candidate per set of commands: the most frequent, then the longest.
|
|
201
|
+
best_by_set: Dict[frozenset, Tuple[Tuple[str, ...], int]] = {}
|
|
202
|
+
for w, c in maximal.items():
|
|
203
|
+
key = frozenset(w)
|
|
204
|
+
cur = best_by_set.get(key)
|
|
205
|
+
if cur is None or (c, len(w)) > (cur[1], len(cur[0])):
|
|
206
|
+
best_by_set[key] = (w, c)
|
|
207
|
+
|
|
208
|
+
out: List[Candidate] = []
|
|
209
|
+
for w, c in best_by_set.values():
|
|
210
|
+
pos = seq_positions[w]
|
|
211
|
+
out.append(Candidate("sequence", list(w), c, originals[pos:pos + len(w)],
|
|
212
|
+
score=c * (len(w) ** 1.5)))
|
|
213
|
+
|
|
214
|
+
cmd_counts = Counter(norms)
|
|
215
|
+
for n, c in cmd_counts.items():
|
|
216
|
+
if c >= max(min_count * 2, 4) and len(n.split()) >= 3:
|
|
217
|
+
if any(n in cand.template for cand in out):
|
|
218
|
+
continue
|
|
219
|
+
idx = max(i for i, x in enumerate(norms) if x == n)
|
|
220
|
+
out.append(Candidate("command", [n], c, [originals[idx]], score=c * 1.2))
|
|
221
|
+
out.sort(key=lambda cand: -cand.score)
|
|
222
|
+
return out[:limit]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _contains(big: Tuple[str, ...], small: Tuple[str, ...]) -> bool:
|
|
226
|
+
n = len(small)
|
|
227
|
+
return any(big[i:i + n] == small for i in range(len(big) - n + 1))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# Drafting
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def placeholders(candidate: Candidate) -> List[str]:
|
|
235
|
+
found: List[str] = []
|
|
236
|
+
for line in candidate.template:
|
|
237
|
+
for ph in re.findall(r"<(?:str|path|file|url|n|hash|redacted)>", line):
|
|
238
|
+
if ph not in found:
|
|
239
|
+
found.append(ph)
|
|
240
|
+
return found
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_PLACEHOLDER_HELP = {
|
|
244
|
+
"<str>": "a quoted text value such as a message or name",
|
|
245
|
+
"<path>": "a file or directory path",
|
|
246
|
+
"<file>": "a file name",
|
|
247
|
+
"<url>": "a URL",
|
|
248
|
+
"<n>": "a number such as a version, port or count",
|
|
249
|
+
"<hash>": "a commit hash or other identifier",
|
|
250
|
+
"<redacted>": "a secret; read it from the environment, never inline it",
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def draft_skill_files(candidate: Candidate, name: Optional[str] = None) -> Dict[str, str]:
|
|
255
|
+
"""Write a deterministic SKILL.md for a mined workflow (no model needed)."""
|
|
256
|
+
name = slugify(name or candidate.name)
|
|
257
|
+
tools = ", ".join(candidate.tools[:4])
|
|
258
|
+
first = candidate.template[0]
|
|
259
|
+
plain = [re.sub(r"<[^>]*>", "...", t) for t in candidate.template[:3]]
|
|
260
|
+
steps = "; ".join(t if len(t) <= 50 else t[:47] + "..." for t in plain)
|
|
261
|
+
desc = ("Runs a recurring %s workflow: %s. Use when the user asks to repeat this sequence "
|
|
262
|
+
"of commands or to automate the same routine." % (tools, steps))
|
|
263
|
+
if len(desc) > 600:
|
|
264
|
+
desc = desc[:597] + "..."
|
|
265
|
+
fm = dump_frontmatter({
|
|
266
|
+
"name": name,
|
|
267
|
+
"description": desc,
|
|
268
|
+
"license": "MIT",
|
|
269
|
+
"metadata": {"version": "0.1.0", "tags": [slugify(t) for t in candidate.tools[:4]] + ["workflow"],
|
|
270
|
+
"source": "askit mine"},
|
|
271
|
+
})
|
|
272
|
+
lines = [fm, "# %s\n" % name.replace("-", " ").capitalize(),
|
|
273
|
+
"This workflow was found %d times in shell history. Review the steps and fill in the "
|
|
274
|
+
"parameters before running them.\n" % candidate.count,
|
|
275
|
+
"## When to use\n",
|
|
276
|
+
"- The user wants to repeat the routine below on a different target.",
|
|
277
|
+
"- The user describes the same goal in different words.\n",
|
|
278
|
+
"## Steps\n"]
|
|
279
|
+
for i, tmpl in enumerate(candidate.template, 1):
|
|
280
|
+
lines.append("%d. Run:\n\n ```bash\n %s\n ```" % (i, tmpl))
|
|
281
|
+
phs = placeholders(candidate)
|
|
282
|
+
if phs:
|
|
283
|
+
lines.append("\n## Parameters\n")
|
|
284
|
+
for ph in phs:
|
|
285
|
+
lines.append("- `%s`: %s" % (ph, _PLACEHOLDER_HELP.get(ph, "a value that changes between runs")))
|
|
286
|
+
lines.append("\n## Example from history\n")
|
|
287
|
+
lines.append("```bash\n%s\n```" % "\n".join(candidate.examples))
|
|
288
|
+
lines.append("\n## Notes\n")
|
|
289
|
+
lines.append("- Stop and report the error if a step fails; do not continue with the remaining steps.")
|
|
290
|
+
lines.append("- Ask the user to confirm any step that deletes or overwrites data.")
|
|
291
|
+
return {"SKILL.md": "\n".join(lines) + "\n"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Built-in offline model used by ``--provider mock``.
|
|
2
|
+
|
|
3
|
+
It always answers with the same small, working skill. That makes the full
|
|
4
|
+
forge loop (draft, lint, scan, test, score, write) demonstrable without
|
|
5
|
+
network access, and keeps the test suite deterministic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
_SKILL_MD = """---
|
|
13
|
+
name: text-stats
|
|
14
|
+
description: Computes word, sentence and line counts, average word length and the most frequent words of a text. Use when the user asks for statistics, a quick readability overview or word frequency of plain text or a file.
|
|
15
|
+
license: MIT
|
|
16
|
+
metadata:
|
|
17
|
+
version: 0.1.0
|
|
18
|
+
tags: [text, analysis, statistics]
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# Text statistics
|
|
22
|
+
|
|
23
|
+
Summarizes a piece of text with a small Python script that has no dependencies.
|
|
24
|
+
|
|
25
|
+
## When to use
|
|
26
|
+
|
|
27
|
+
- The user pastes text or points to a file and wants counts or word frequency.
|
|
28
|
+
- The user wants a quick sense of length before editing or summarizing.
|
|
29
|
+
|
|
30
|
+
## How to run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python scripts/text_stats.py --top 5 notes.txt
|
|
34
|
+
cat notes.txt | python scripts/text_stats.py
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The script reads the file given as the last argument, or standard input when no file is given.
|
|
38
|
+
|
|
39
|
+
## Output
|
|
40
|
+
|
|
41
|
+
A JSON object with `words`, `sentences`, `lines`, `avg_word_length` and `top_words`
|
|
42
|
+
(a list of `[word, count]` pairs). Words shorter than three letters are ignored in `top_words`.
|
|
43
|
+
|
|
44
|
+
## Example
|
|
45
|
+
|
|
46
|
+
Input: `The cat sat. The cat ran.`
|
|
47
|
+
|
|
48
|
+
Output: `{"words": 6, "sentences": 2, "lines": 1, "avg_word_length": 3.0, "top_words": [["the", 2], ["cat", 2], ["sat", 1], ["ran", 1]]}`
|
|
49
|
+
|
|
50
|
+
## Errors
|
|
51
|
+
|
|
52
|
+
A missing file exits with code 2 and a message on stderr. Empty input returns zero counts.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_SCRIPT = '''#!/usr/bin/env python3
|
|
56
|
+
"""Print word, sentence and line statistics for text."""
|
|
57
|
+
import argparse
|
|
58
|
+
import json
|
|
59
|
+
import re
|
|
60
|
+
import sys
|
|
61
|
+
from collections import Counter
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def analyze(text, top=5):
|
|
65
|
+
words = re.findall(r"[A-Za-z0-9']+", text)
|
|
66
|
+
sentences = [s for s in re.split(r"[.!?]+", text) if s.strip()]
|
|
67
|
+
lines = [ln for ln in text.splitlines() if ln.strip()]
|
|
68
|
+
counts = Counter(w.lower() for w in words if len(w) >= 3)
|
|
69
|
+
avg = round(sum(len(w) for w in words) / len(words), 2) if words else 0.0
|
|
70
|
+
return {
|
|
71
|
+
"words": len(words),
|
|
72
|
+
"sentences": len(sentences),
|
|
73
|
+
"lines": len(lines),
|
|
74
|
+
"avg_word_length": avg,
|
|
75
|
+
"top_words": [[w, c] for w, c in counts.most_common(top)],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(argv=None):
|
|
80
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
81
|
+
parser.add_argument("file", nargs="?", help="text file (reads stdin when omitted)")
|
|
82
|
+
parser.add_argument("--top", type=int, default=5, help="number of frequent words to list")
|
|
83
|
+
args = parser.parse_args(argv)
|
|
84
|
+
if args.file:
|
|
85
|
+
try:
|
|
86
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
87
|
+
text = fh.read()
|
|
88
|
+
except OSError:
|
|
89
|
+
print("file not found: %s" % args.file, file=sys.stderr)
|
|
90
|
+
return 2
|
|
91
|
+
else:
|
|
92
|
+
text = sys.stdin.read()
|
|
93
|
+
print(json.dumps(analyze(text, args.top)))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
|
99
|
+
'''
|
|
100
|
+
|
|
101
|
+
_CASES = """{
|
|
102
|
+
"entry": "scripts/text_stats.py",
|
|
103
|
+
"cases": [
|
|
104
|
+
{"name": "counts words and sentences", "kind": "happy",
|
|
105
|
+
"stdin": "The cat sat. The cat ran.",
|
|
106
|
+
"stdout_json": {"words": 6, "sentences": 2, "top_words": [["the", 2], ["cat", 2], ["sat", 1], ["ran", 1]]}},
|
|
107
|
+
{"name": "empty input", "kind": "edge", "stdin": "",
|
|
108
|
+
"stdout_json": {"words": 0, "sentences": 0, "lines": 0}},
|
|
109
|
+
{"name": "missing file", "kind": "error", "args": ["nope.txt"],
|
|
110
|
+
"expect_exit": 2, "stderr_contains": "file not found"}
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def draft_files() -> Dict[str, str]:
|
|
117
|
+
return {"SKILL.md": _SKILL_MD, "scripts/text_stats.py": _SCRIPT, "tests/cases.json": _CASES}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def mock_reply(messages: List[Dict[str, str]]) -> str:
|
|
121
|
+
parts = []
|
|
122
|
+
for path, content in draft_files().items():
|
|
123
|
+
parts.append("=== FILE: %s ===\n%s\n=== END FILE ===" % (path, content.rstrip("\n")))
|
|
124
|
+
return "\n\n".join(parts)
|