help2skill 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.
help2skill/sync.py ADDED
@@ -0,0 +1,448 @@
1
+ """Keep a hand-edited SKILL.md in sync with the real CLI.
2
+
3
+ Two kinds of drift are detected:
4
+
5
+ * the generated reference block (between markers) is stale;
6
+ * hand-written text outside the block mentions a subcommand or option the CLI
7
+ does not have, or uses an option at the wrong level.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import re
14
+ import shlex
15
+ from dataclasses import dataclass, field
16
+
17
+ from .model import Command
18
+ from .privacy import redact_home
19
+ from .render import SKILL_MAX_CHARS, render_index, render_markdown
20
+
21
+ BEGIN = "<!-- help2skill:begin (generated from the CLI; edit outside this block) -->"
22
+ END = "<!-- help2skill:end -->"
23
+ _BLOCK_RE = re.compile(r"<!-- help2skill:begin[^\n]*?-->\n?(.*?)<!-- help2skill:end -->", re.S)
24
+ _BEGIN_RE = re.compile(r"<!-- help2skill:begin([^\n(]*)")
25
+ _SETTING_RE = re.compile(r"([a-z-]+)=(\S+)")
26
+ _CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
27
+ _FENCE_RE = re.compile(r"^(```|~~~)")
28
+ _ALWAYS_OK = {"--help", "-h", "--llms"}
29
+ _STOP_TOKENS = {"|", "||", "&&", ";", ">", ">>", "<", "--"}
30
+ _ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
31
+ # Launchers that run the real command, with their options that take a value.
32
+ _UV_RUN_VALUE_OPTS = {
33
+ "--with", "--with-editable", "--with-requirements", "--python", "-p", "--project",
34
+ "--directory", "--extra", "--group", "--only-group", "--package", "--env-file", "--from", "--index",
35
+ } # fmt: skip
36
+ _RUNNER_PREFIXES = {("poetry", "run"), ("pdm", "run"), ("hatch", "run"), ("rye", "run"), ("pipx", "run")}
37
+
38
+ REFERENCE_FILE = "references/commands.md"
39
+ REFERENCE_HEADER = (
40
+ "<!-- Generated by help2skill from the CLI. Do not edit: run `help2skill skill` to regenerate. -->\n\n"
41
+ )
42
+
43
+
44
+ @dataclass
45
+ class Settings:
46
+ """Options used to generate a block, stored in its begin marker.
47
+
48
+ ``help2skill check`` and :func:`help2skill.testing.assert_skill_in_sync` reuse
49
+ them, so they never have to be repeated.
50
+ """
51
+
52
+ program: str | None = None
53
+ max_chars: int | None = None
54
+ hide_defaults: list[str] = field(default_factory=list)
55
+
56
+ def marker(self) -> str:
57
+ parts = []
58
+ if self.program:
59
+ parts.append(f"program={self.program}")
60
+ if self.max_chars is not None:
61
+ parts.append(f"max-chars={self.max_chars}")
62
+ if self.hide_defaults:
63
+ parts.append("hide-default=" + ",".join(self.hide_defaults))
64
+ settings = " " + " ".join(parts) if parts else ""
65
+ return f"<!-- help2skill:begin{settings} (generated from the CLI; edit outside this block) -->"
66
+
67
+
68
+ def read_settings(text: str) -> Settings:
69
+ """Settings stored in the begin marker of ``text`` (empty if none)."""
70
+ match = _BEGIN_RE.search(text)
71
+ if not match:
72
+ return Settings()
73
+ values = dict(_SETTING_RE.findall(match.group(1)))
74
+ max_chars = values.get("max-chars")
75
+ return Settings(
76
+ program=values.get("program"),
77
+ max_chars=int(max_chars) if max_chars and max_chars.lstrip("-").isdigit() else None,
78
+ hide_defaults=[v for v in values.get("hide-default", "").split(",") if v],
79
+ )
80
+
81
+
82
+ @dataclass
83
+ class Problem:
84
+ # missing-block | stale-block | missing-reference | stale-reference |
85
+ # unknown-command | wrong-level-option | unknown-option | flag-value | invalid-choice
86
+ kind: str
87
+ message: str
88
+ line: int | None = None
89
+
90
+ def __str__(self) -> str:
91
+ where = f"line {self.line}: " if self.line else ""
92
+ return f"{where}{self.message} [{self.kind}]"
93
+
94
+
95
+ def wrap_block(generated: str, settings: Settings | None = None) -> str:
96
+ begin = settings.marker() if settings else BEGIN
97
+ return f"{begin}\n{generated.rstrip()}\n{END}\n"
98
+
99
+
100
+ def extract_block(text: str) -> str | None:
101
+ match = _BLOCK_RE.search(text)
102
+ return match.group(1) if match else None
103
+
104
+
105
+ def update_block(text: str, generated: str, settings: Settings | None = None) -> str:
106
+ """Replace the generated block in ``text``, or append one if missing."""
107
+ block = wrap_block(generated, settings)
108
+ if _BLOCK_RE.search(text):
109
+ return _BLOCK_RE.sub(lambda _: block.rstrip("\n"), text, count=1)
110
+ return text.rstrip("\n") + "\n\n" + block
111
+
112
+
113
+ def skill_parts(cmd: Command, *, max_chars: int = SKILL_MAX_CHARS) -> tuple[str, str | None]:
114
+ """Return the SKILL.md block and, for large CLIs, the reference file content."""
115
+ full = redact_home(render_markdown(cmd, level=2))
116
+ if max_chars <= 0 or len(full) <= max_chars or not cmd.is_group:
117
+ return full, None
118
+ hint = (
119
+ f"Full reference for every command: [{REFERENCE_FILE}]({REFERENCE_FILE}). "
120
+ "Read only the section of the command you need."
121
+ )
122
+ block = redact_home(render_index(cmd, level=2, hint=hint))
123
+ # SKILL.md already has the index of every command; list only direct ones here.
124
+ return block, REFERENCE_HEADER + redact_home(render_markdown(cmd, level=1, index=False))
125
+
126
+
127
+ def check(
128
+ text: str,
129
+ cmd: Command,
130
+ *,
131
+ max_chars: int = SKILL_MAX_CHARS,
132
+ reference: str | None = None,
133
+ ) -> list[Problem]:
134
+ """Return every drift problem between ``text`` (a SKILL.md) and ``cmd``.
135
+
136
+ ``reference`` is the content of ``references/commands.md`` if it exists.
137
+ """
138
+ problems: list[Problem] = []
139
+ current = extract_block(text)
140
+ expected, expected_reference = skill_parts(cmd, max_chars=max_chars)
141
+ if current is None:
142
+ problems.append(Problem("missing-block", "no help2skill generated block found"))
143
+ elif current.strip() != expected.strip():
144
+ diff = "\n".join(
145
+ difflib.unified_diff(
146
+ current.strip().splitlines(),
147
+ expected.strip().splitlines(),
148
+ "SKILL.md",
149
+ "CLI",
150
+ lineterm="",
151
+ )
152
+ )
153
+ message = "generated block is out of date"
154
+ if _without_defaults(current) == _without_defaults(expected):
155
+ message += (
156
+ " (only default values differ: if they are computed at run time, such as a CPU "
157
+ "count or a date, leave them out with --hide-default NAME)"
158
+ )
159
+ problems.append(Problem("stale-block", message + ":\n" + diff))
160
+ if expected_reference is not None and reference is None:
161
+ problems.append(Problem("missing-reference", f"{REFERENCE_FILE} is missing"))
162
+ elif expected_reference is not None and reference is not None:
163
+ if reference.strip() != expected_reference.strip():
164
+ problems.append(Problem("stale-reference", f"{REFERENCE_FILE} is out of date"))
165
+ problems.extend(check_references(_without_block(text), cmd))
166
+ return problems
167
+
168
+
169
+ _DEFAULT_NOTE_RE = re.compile(r"default: [^;)]*")
170
+
171
+
172
+ def _without_defaults(text: str) -> str:
173
+ return _DEFAULT_NOTE_RE.sub("default", text).strip()
174
+
175
+
176
+ def check_references(text: str, cmd: Command) -> list[Problem]:
177
+ """Check command lines in code spans and fenced blocks of hand-written text."""
178
+ problems = []
179
+ for lineno, snippet in _snippets(text):
180
+ problems.extend(_check_invocation(snippet, cmd, lineno))
181
+ return problems
182
+
183
+
184
+ def _without_block(text: str) -> str:
185
+ # Keep line numbers stable by blanking the block instead of removing it.
186
+ return _BLOCK_RE.sub(lambda m: "\n" * m.group(0).count("\n"), text)
187
+
188
+
189
+ def _snippets(text: str):
190
+ in_fence = False
191
+ pending: tuple[int, str] | None = None # a command continued with a trailing backslash
192
+ for lineno, line in enumerate(text.splitlines(), start=1):
193
+ if _FENCE_RE.match(line.strip()):
194
+ in_fence = not in_fence
195
+ if pending:
196
+ yield pending
197
+ pending = None
198
+ continue
199
+ if in_fence:
200
+ if pending:
201
+ lineno, line = pending[0], pending[1] + " " + line.strip()
202
+ pending = None
203
+ if line.rstrip().endswith("\\"):
204
+ pending = (lineno, line.rstrip()[:-1])
205
+ continue
206
+ yield lineno, line
207
+ else:
208
+ for match in _CODE_SPAN_RE.finditer(line):
209
+ yield lineno, match.group(1)
210
+
211
+
212
+ def _check_invocation(snippet: str, root: Command, lineno: int) -> list[Problem]:
213
+ snippet = snippet.strip().removeprefix("$ ")
214
+ try:
215
+ tokens = shlex.split(snippet, comments=True)
216
+ except ValueError:
217
+ return []
218
+ tokens = _strip_launchers(tokens, root.name)
219
+ if not tokens:
220
+ return []
221
+ return _Walker(root, lineno).run(tokens[1:])
222
+
223
+
224
+ class _Walker:
225
+ """Follow one command line through the command tree, the way the parser would."""
226
+
227
+ def __init__(self, root: Command, lineno: int) -> None:
228
+ self.node = root
229
+ self.ancestors = [root]
230
+ self.lineno = lineno
231
+ self.problems: list[Problem] = []
232
+ self.positional = 0
233
+
234
+ def report(self, kind: str, message: str) -> None:
235
+ self.problems.append(Problem(kind, message, self.lineno))
236
+
237
+ def run(self, tokens: list[str]) -> list[Problem]:
238
+ i = 0
239
+ while i < len(tokens):
240
+ token = tokens[i]
241
+ if token in _STOP_TOKENS:
242
+ break
243
+ if token.startswith("-") and token != "-":
244
+ i = self.option(tokens, i)
245
+ continue
246
+ if not self.positional_token(token):
247
+ break
248
+ i += 1
249
+ return self.problems
250
+
251
+ # -- options ---------------------------------------------------------------
252
+ def option(self, tokens: list[str], i: int) -> int:
253
+ token = tokens[i]
254
+ flag, has_value, value = token.partition("=")
255
+ if flag in _ALWAYS_OK:
256
+ return i + 1
257
+ param = _find_option(self.node, flag)
258
+ if param is None and not flag.startswith("--") and len(flag) > 2 and not has_value:
259
+ return self.short_cluster(tokens, i)
260
+ if param is None:
261
+ self.misplaced_option(flag)
262
+ # Skip a likely value so it is not mistaken for a subcommand.
263
+ nxt = tokens[i + 1] if i + 1 < len(tokens) else None
264
+ if not has_value and nxt is not None and not nxt.startswith("-") and self.node.get(nxt) is None:
265
+ return i + 2
266
+ return i + 1
267
+ if param.is_flag:
268
+ if has_value:
269
+ self.report("flag-value", f"`{flag}` is a flag and takes no value")
270
+ return i + 1
271
+ if has_value:
272
+ self.check_choice(param, value)
273
+ return i + 1
274
+ return self.consume_values(param, tokens, i + 1)
275
+
276
+ def short_cluster(self, tokens: list[str], i: int) -> int:
277
+ """``-vv``, ``-fq`` or ``-n5``: single-letter options written together."""
278
+ token = tokens[i]
279
+ for pos in range(1, len(token)):
280
+ short = "-" + token[pos]
281
+ param = _find_option(self.node, short)
282
+ if param is None:
283
+ self.misplaced_option(short, cluster=token)
284
+ return i + 1
285
+ if not param.is_flag:
286
+ rest = token[pos + 1 :]
287
+ if rest:
288
+ self.check_choice(param, rest)
289
+ return i + 1
290
+ return self.consume_values(param, tokens, i + 1)
291
+ return i + 1
292
+
293
+ def consume_values(self, param, tokens: list[str], i: int) -> int:
294
+ if param.value_tokens:
295
+ count: int | None = len(param.value_tokens)
296
+ elif isinstance(param.nargs, int) and param.nargs > 1:
297
+ count = param.nargs
298
+ elif param.nargs in ("*", "+", "?") or param.optional_value:
299
+ count = None
300
+ else:
301
+ count = 1
302
+ if count is None:
303
+ limit = 1 if param.nargs == "?" or param.optional_value else len(tokens)
304
+ taken = 0
305
+ while i < len(tokens) and taken < limit and not tokens[i].startswith("-"):
306
+ if tokens[i] in _STOP_TOKENS:
307
+ break
308
+ self.check_choice(param, tokens[i])
309
+ i += 1
310
+ taken += 1
311
+ return i
312
+ for k in range(count):
313
+ if i + k < len(tokens):
314
+ if k == 0 or not param.value_tokens:
315
+ self.check_choice(param, tokens[i + k])
316
+ return i + count
317
+
318
+ def check_choice(self, param, value: str) -> None:
319
+ if not param.choices or _is_placeholder(value):
320
+ return
321
+ allowed = param.choices
322
+ if param.case_insensitive:
323
+ ok = value.casefold() in {c.casefold() for c in allowed}
324
+ else:
325
+ ok = value in allowed
326
+ if not ok:
327
+ name = max(param.opts, key=len) if param.is_option else param.name.upper()
328
+ self.report(
329
+ "invalid-choice",
330
+ f"`{value}` is not a valid value for `{name}` in `{_path(self.node)}` "
331
+ f"(choose from: {', '.join(allowed)})",
332
+ )
333
+
334
+ def misplaced_option(self, flag: str, *, cluster: str | None = None) -> None:
335
+ owner = next((a for a in self.ancestors if _find_option(a, flag)), None)
336
+ if owner is not None:
337
+ self.report(
338
+ "wrong-level-option",
339
+ f"`{flag}` belongs to `{_path(owner)}`, not `{_path(self.node)}` "
340
+ "(place it before the subcommand)",
341
+ )
342
+ return
343
+ below = next((c for c in self.node.walk() if c is not self.node and _find_option(c, flag)), None)
344
+ if below is not None:
345
+ self.report(
346
+ "wrong-level-option",
347
+ f"`{flag}` belongs to `{_path(below)}`, not `{_path(self.node)}` "
348
+ f"(place it after `{below.path[-1]}`)",
349
+ )
350
+ return
351
+ where = f" (in `{cluster}`)" if cluster else ""
352
+ message = f"`{_path(self.node)}` has no option `{flag}`{where}"
353
+ names = [o for p in self.node.options for o in (*p.opts, *p.secondary_opts)]
354
+ message += _did_you_mean(flag, names)
355
+ self.report("unknown-option", message)
356
+
357
+ # -- positionals -------------------------------------------------------------
358
+ def positional_token(self, token: str) -> bool:
359
+ """Handle a non-option token; return False to stop checking the line."""
360
+ node = self.node
361
+ if node.is_group:
362
+ child = node.get(token)
363
+ if child is not None:
364
+ self.enter(child)
365
+ return True
366
+ if token in node.hidden_subcommands:
367
+ return False
368
+ default = node.get(node.default_command) if node.default_command else None
369
+ if default is not None:
370
+ # The token is the first argument of the default subcommand.
371
+ self.enter(default)
372
+ return self.positional_token(token)
373
+ if _is_placeholder(token):
374
+ return False
375
+ names = [c.name for c in node.subcommands] + [a for c in node.subcommands for a in c.aliases]
376
+ self.report(
377
+ "unknown-command",
378
+ f"`{_path(node)}` has no subcommand `{token}`" + _did_you_mean(token, names),
379
+ )
380
+ return False
381
+ # Leaf command: check the value against the matching argument's choices.
382
+ args = node.arguments
383
+ if self.positional < len(args):
384
+ arg = args[self.positional]
385
+ self.check_choice(arg, token)
386
+ if not arg.multiple:
387
+ self.positional += 1
388
+ return True
389
+
390
+ def enter(self, child: Command) -> None:
391
+ self.node = child
392
+ self.ancestors.append(child)
393
+ self.positional = 0
394
+
395
+
396
+ def _path(cmd: Command) -> str:
397
+ return " ".join(cmd.path)
398
+
399
+
400
+ def _did_you_mean(word: str, candidates: list[str]) -> str:
401
+ close = difflib.get_close_matches(word, candidates, n=1, cutoff=0.6)
402
+ return f"; did you mean `{close[0]}`?" if close else ""
403
+
404
+
405
+ def _strip_launchers(tokens: list[str], root_name: str) -> list[str]:
406
+ """Drop ``VAR=x``, ``uv run …``, ``python -m mod`` etc. so the CLI name comes first.
407
+
408
+ Returns an empty list when the line does not invoke ``root_name``.
409
+ """
410
+ i = 0
411
+ while i < len(tokens):
412
+ token = tokens[i]
413
+ if token == root_name:
414
+ return tokens[i:]
415
+ if _ENV_ASSIGN_RE.match(token):
416
+ i += 1
417
+ elif token == "uv" and tokens[i + 1 : i + 3] == ["tool", "run"]:
418
+ i = _skip_options(tokens, i + 3)
419
+ elif (token == "uv" and tokens[i + 1 : i + 2] == ["run"]) or token == "uvx":
420
+ i = _skip_options(tokens, i + (2 if token == "uv" else 1))
421
+ elif tuple(tokens[i : i + 2]) in _RUNNER_PREFIXES:
422
+ i = _skip_options(tokens, i + 2)
423
+ elif re.fullmatch(r"python(\d+(\.\d+)?)?", token) and tokens[i + 1 : i + 2] == ["-m"]:
424
+ module = tokens[i + 2] if i + 2 < len(tokens) else ""
425
+ if module.replace("_", "-") != root_name.replace("_", "-"):
426
+ return []
427
+ return [root_name, *tokens[i + 3 :]]
428
+ else:
429
+ return []
430
+ return []
431
+
432
+
433
+ def _skip_options(tokens: list[str], i: int) -> int:
434
+ while i < len(tokens) and tokens[i].startswith("-"):
435
+ takes_value = tokens[i] in _UV_RUN_VALUE_OPTS and "=" not in tokens[i]
436
+ i += 2 if takes_value else 1
437
+ return i
438
+
439
+
440
+ def _find_option(cmd: Command, flag: str):
441
+ for p in cmd.options:
442
+ if flag in p.opts or flag in p.secondary_opts:
443
+ return p
444
+ return None
445
+
446
+
447
+ def _is_placeholder(token: str) -> bool:
448
+ return token.isupper() or token.startswith(("<", "{", "[", "$")) or token in {"...", "…"}
help2skill/testing.py ADDED
@@ -0,0 +1,37 @@
1
+ """pytest helper: fail the build when SKILL.md drifts from the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from . import check_skill_file
9
+ from ._click import Resolver
10
+
11
+
12
+ def assert_skill_in_sync(
13
+ cli: Any,
14
+ path: str | Path,
15
+ *,
16
+ program: str | None = None,
17
+ max_chars: int | None = None,
18
+ resolve: Resolver | None = None,
19
+ hide_defaults: list[str] | None = None,
20
+ ) -> None:
21
+ """Raise ``AssertionError`` listing every drift problem.
22
+
23
+ Settings not passed are read from the file (``help2skill skill`` stores them).
24
+ Build ``path`` from ``__file__`` so the test passes from any directory.
25
+ """
26
+ problems = check_skill_file(
27
+ cli, path, program=program, max_chars=max_chars, resolve=resolve, hide_defaults=hide_defaults
28
+ )
29
+ if problems:
30
+ details = "\n".join(f"- {p}" for p in problems)
31
+ hint = ""
32
+ if any(
33
+ p.kind in ("stale-block", "missing-block", "stale-reference", "missing-reference")
34
+ for p in problems
35
+ ):
36
+ hint = f"\nRegenerate the block with: help2skill skill MODULE:ATTR -o {path}"
37
+ raise AssertionError(f"{path} is out of sync with the CLI:\n{details}{hint}")
help2skill/typer.py ADDED
@@ -0,0 +1,42 @@
1
+ """Typer integration.
2
+
3
+ Two ways to get ``--llms``:
4
+
5
+ * ``LlmsOption`` as a parameter of the app callback: root level only, no change
6
+ to your entry point.
7
+ * ``command(app)`` as your entry point: ``--llms`` on every command, so
8
+ ``tool sub --llms`` prints just that subtree.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Annotated, Any
14
+
15
+ import typer as _typer
16
+
17
+ from .click import LLMS_HELP, add_llms_option, print_llms
18
+
19
+
20
+ def _llms_callback(ctx: _typer.Context, value: bool) -> None:
21
+ print_llms(ctx, None, value)
22
+
23
+
24
+ LlmsOption = Annotated[
25
+ bool,
26
+ _typer.Option("--llms", callback=_llms_callback, is_eager=True, help=LLMS_HELP),
27
+ ]
28
+
29
+
30
+ def command(app: _typer.Typer, name: str | None = None) -> Any:
31
+ """Build the Click command for ``app`` with ``--llms`` at every level.
32
+
33
+ Use the result as the console-script entry point::
34
+
35
+ main = help2skill.typer.command(app)
36
+ """
37
+ import typer.main
38
+
39
+ cmd = typer.main.get_command(app)
40
+ if name:
41
+ cmd.name = name
42
+ return add_llms_option(cmd)