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/__init__.py +243 -0
- help2skill/__main__.py +3 -0
- help2skill/_argparse.py +173 -0
- help2skill/_click.py +256 -0
- help2skill/cli.py +264 -0
- help2skill/click.py +98 -0
- help2skill/model.py +89 -0
- help2skill/privacy.py +33 -0
- help2skill/py.typed +0 -0
- help2skill/render.py +286 -0
- help2skill/sync.py +448 -0
- help2skill/testing.py +37 -0
- help2skill/typer.py +42 -0
- help2skill-0.1.0.dist-info/METADATA +206 -0
- help2skill-0.1.0.dist-info/RECORD +18 -0
- help2skill-0.1.0.dist-info/WHEEL +4 -0
- help2skill-0.1.0.dist-info/entry_points.txt +2 -0
- help2skill-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|