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/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)