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/render.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Render a :class:`Command` tree as Markdown for LLM agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .model import Command, Param
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .sync import Settings
|
|
12
|
+
|
|
13
|
+
SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
14
|
+
|
|
15
|
+
# Above these sizes output switches to an index (about 5k and 8k tokens).
|
|
16
|
+
LLMS_MAX_CHARS = 20_000
|
|
17
|
+
SKILL_MAX_CHARS = 30_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def usage(cmd: Command) -> str:
|
|
21
|
+
"""One-line invocation signature, e.g. ``tool db migrate [OPTIONS] NAME``."""
|
|
22
|
+
parts = [" ".join(cmd.path)]
|
|
23
|
+
if cmd.options:
|
|
24
|
+
parts.append("[OPTIONS]")
|
|
25
|
+
for arg in cmd.arguments:
|
|
26
|
+
parts.append(_argument_token(arg))
|
|
27
|
+
if cmd.is_group:
|
|
28
|
+
parts.append("COMMAND [ARGS]...")
|
|
29
|
+
return " ".join(parts)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_markdown(cmd: Command, *, level: int = 1, index: bool = True) -> str:
|
|
33
|
+
"""Render ``cmd`` and all its subcommands.
|
|
34
|
+
|
|
35
|
+
The root gets a heading at ``level``; every descendant gets ``level + 1``
|
|
36
|
+
with its full path in the heading, so an agent can grep for one command.
|
|
37
|
+
``index=False`` lists only direct subcommands at the root instead of all.
|
|
38
|
+
Options repeated identically in several commands are listed once.
|
|
39
|
+
"""
|
|
40
|
+
shared = _shared_options(cmd)
|
|
41
|
+
root = _section(cmd, level, index=index, shared=shared)
|
|
42
|
+
if shared:
|
|
43
|
+
root += "\n\nShared options (listed by name in the commands that accept them):\n"
|
|
44
|
+
root += "\n".join(shared)
|
|
45
|
+
sections = [root]
|
|
46
|
+
for sub in cmd.walk():
|
|
47
|
+
if sub is not cmd:
|
|
48
|
+
sections.append(_section(sub, level + 1, index=False, shared=shared))
|
|
49
|
+
return "\n\n".join(sections).rstrip() + "\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# An option line is listed once when it repeats in this many commands...
|
|
53
|
+
SHARED_MIN_COMMANDS = 3
|
|
54
|
+
# ...and is at least this long (short lines cost less than the reference to them).
|
|
55
|
+
SHARED_MIN_CHARS = 60
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _shared_options(cmd: Command) -> list[str]:
|
|
59
|
+
counts: dict[str, int] = {}
|
|
60
|
+
for node in cmd.walk():
|
|
61
|
+
for line in {_param_line(p) for p in node.options}:
|
|
62
|
+
counts[line] = counts.get(line, 0) + 1
|
|
63
|
+
return [line for line, n in counts.items() if n >= SHARED_MIN_COMMANDS and len(line) >= SHARED_MIN_CHARS]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render_index(cmd: Command, *, level: int = 1, hint: str | None = None) -> str:
|
|
67
|
+
"""Render only ``cmd`` itself plus an index of every command below it."""
|
|
68
|
+
text = _section(cmd, level, index=True)
|
|
69
|
+
if hint:
|
|
70
|
+
text += f"\n\n{hint}"
|
|
71
|
+
return text + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_llms(cmd: Command, *, max_chars: int = LLMS_MAX_CHARS, subcommand_flag: bool = True) -> str:
|
|
75
|
+
"""What ``--llms`` prints: the full reference, or an index when it is too long.
|
|
76
|
+
|
|
77
|
+
``max_chars <= 0`` disables the limit. ``subcommand_flag`` says whether
|
|
78
|
+
subcommands accept ``--llms`` too; the index then points the agent at it,
|
|
79
|
+
otherwise at ``--help``.
|
|
80
|
+
"""
|
|
81
|
+
full = render_markdown(cmd)
|
|
82
|
+
if max_chars <= 0 or len(full) <= max_chars or not cmd.is_group:
|
|
83
|
+
return full
|
|
84
|
+
path = " ".join(cmd.path)
|
|
85
|
+
how = f"`{path} <command> --llms`" if subcommand_flag else f"`{path} <command> --help`"
|
|
86
|
+
hint = f"Index only: the full reference is {len(full):,} characters. For one command, run {how}."
|
|
87
|
+
return render_index(cmd, hint=hint)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def render_skill(
|
|
91
|
+
cmd: Command,
|
|
92
|
+
*,
|
|
93
|
+
name: str,
|
|
94
|
+
description: str,
|
|
95
|
+
body: str | None = None,
|
|
96
|
+
block: str | None = None,
|
|
97
|
+
settings: Settings | None = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Render a complete ``SKILL.md`` (Agent Skills format).
|
|
100
|
+
|
|
101
|
+
``body`` is free-form, hand-written guidance placed before the generated
|
|
102
|
+
reference. The reference sits between markers so it can be regenerated
|
|
103
|
+
without touching the hand-written parts (see :mod:`help2skill.sync`).
|
|
104
|
+
"""
|
|
105
|
+
from .sync import wrap_block
|
|
106
|
+
|
|
107
|
+
validate_skill_metadata(name, description)
|
|
108
|
+
front = f"---\nname: {name}\ndescription: {_yaml_str(description)}\n---\n"
|
|
109
|
+
intro = body.strip() + "\n\n" if body else ""
|
|
110
|
+
generated = block if block is not None else render_markdown(cmd, level=2)
|
|
111
|
+
return f"{front}\n{intro}{wrap_block(generated, settings)}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def default_description(cmd: Command) -> str:
|
|
115
|
+
summary = cmd.short_help or (cmd.help or "").split("\n\n")[0].replace("\n", " ")
|
|
116
|
+
text = f"Use the `{cmd.name}` command-line tool."
|
|
117
|
+
if summary:
|
|
118
|
+
text += f" {summary.rstrip('.')}."
|
|
119
|
+
return text[:1024]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_skill_metadata(name: str, description: str) -> None:
|
|
123
|
+
if not (1 <= len(name) <= 64) or not SKILL_NAME_RE.match(name):
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"invalid skill name {name!r}: use 1-64 lowercase letters, digits and single hyphens"
|
|
126
|
+
)
|
|
127
|
+
if not description.strip():
|
|
128
|
+
raise ValueError("skill description must not be empty")
|
|
129
|
+
if len(description) > 1024:
|
|
130
|
+
raise ValueError("skill description must be at most 1024 characters")
|
|
131
|
+
if "<" in description or ">" in description:
|
|
132
|
+
raise ValueError("skill description must not contain angle brackets")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _section(cmd: Command, level: int, *, index: bool, shared: list[str] | None = None) -> str:
|
|
136
|
+
title = f"{'#' * level} `{usage(cmd)}`"
|
|
137
|
+
if cmd.deprecated:
|
|
138
|
+
title += " (deprecated)"
|
|
139
|
+
lines = [title, ""]
|
|
140
|
+
if cmd.aliases:
|
|
141
|
+
lines += ["Aliases: " + ", ".join(f"`{a}`" for a in cmd.aliases), ""]
|
|
142
|
+
if cmd.help:
|
|
143
|
+
lines += [cmd.help, ""]
|
|
144
|
+
if cmd.arguments:
|
|
145
|
+
lines += ["Arguments:", *(_param_line(p) for p in cmd.arguments), ""]
|
|
146
|
+
if cmd.options:
|
|
147
|
+
own, common = [], []
|
|
148
|
+
for p in cmd.options:
|
|
149
|
+
line = _param_line(p)
|
|
150
|
+
if shared and line in shared:
|
|
151
|
+
common.append(_option_names(p))
|
|
152
|
+
else:
|
|
153
|
+
own.append(line)
|
|
154
|
+
lines.append("Options:")
|
|
155
|
+
lines += own
|
|
156
|
+
if common:
|
|
157
|
+
lines.append("- Shared options: " + ", ".join(f"`{c}`" for c in common))
|
|
158
|
+
lines.append("")
|
|
159
|
+
for names, required in cmd.exclusive:
|
|
160
|
+
text = "Mutually exclusive: " + ", ".join(f"`{n}`" for n in names)
|
|
161
|
+
lines += [text + (" (one is required)" if required else ""), ""]
|
|
162
|
+
if cmd.is_group:
|
|
163
|
+
if cmd.default_command:
|
|
164
|
+
prog = " ".join(cmd.path)
|
|
165
|
+
lines += [
|
|
166
|
+
f"Default command: `{cmd.default_command}`. When the first argument is not a command "
|
|
167
|
+
f"name, `{prog} ARGS...` runs `{prog} {cmd.default_command} ARGS...`.",
|
|
168
|
+
"",
|
|
169
|
+
]
|
|
170
|
+
lines.append("Commands:" if not index else "Commands (all levels):")
|
|
171
|
+
children = [c for c in cmd.walk() if c is not cmd] if index else cmd.subcommands
|
|
172
|
+
defaults = {tuple(node.path + [node.default_command]) for node in cmd.walk() if node.default_command}
|
|
173
|
+
for child in children:
|
|
174
|
+
lines.append(_index_line(child, is_default=tuple(child.path) in defaults))
|
|
175
|
+
lines.append("")
|
|
176
|
+
return "\n".join(lines).rstrip()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _index_line(cmd: Command, *, is_default: bool = False) -> str:
|
|
180
|
+
flags = []
|
|
181
|
+
if is_default:
|
|
182
|
+
flags.append("default command")
|
|
183
|
+
if cmd.deprecated:
|
|
184
|
+
flags.append("deprecated")
|
|
185
|
+
if cmd.aliases:
|
|
186
|
+
flags.append("aliases: " + ", ".join(cmd.aliases))
|
|
187
|
+
text = f"- `{' '.join(cmd.path)}`"
|
|
188
|
+
if flags:
|
|
189
|
+
text += f" ({'; '.join(flags)})"
|
|
190
|
+
if cmd.short_help:
|
|
191
|
+
text += f": {cmd.short_help}"
|
|
192
|
+
return text
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _param_line(p: Param) -> str:
|
|
196
|
+
sig = _argument_token(p) if not p.is_option else _option_sig(p)
|
|
197
|
+
notes = []
|
|
198
|
+
if not p.is_option:
|
|
199
|
+
notes.append("one of: " + ", ".join(p.choices) if p.choices else p.type)
|
|
200
|
+
if p.range:
|
|
201
|
+
notes.append(f"range: {p.range}")
|
|
202
|
+
if p.case_insensitive:
|
|
203
|
+
notes.append("case-insensitive")
|
|
204
|
+
if p.required:
|
|
205
|
+
notes.append("required")
|
|
206
|
+
if p.count:
|
|
207
|
+
notes.append(f"repeat to increase, e.g. {_repeat_example(p)}")
|
|
208
|
+
elif p.multiple and p.is_option:
|
|
209
|
+
notes.append("repeatable")
|
|
210
|
+
if p.optional_value and not p.is_flag:
|
|
211
|
+
notes.append("value optional")
|
|
212
|
+
if p.default is not None and "default" not in (p.help or "").lower():
|
|
213
|
+
notes.append(f"default: {p.default}")
|
|
214
|
+
if p.envvars:
|
|
215
|
+
notes.append("env: " + ", ".join(p.envvars))
|
|
216
|
+
if p.prompts:
|
|
217
|
+
notes.append("prompts when omitted: pass it explicitly")
|
|
218
|
+
text = f"- `{sig}`"
|
|
219
|
+
if notes:
|
|
220
|
+
text += f" ({'; '.join(notes)})"
|
|
221
|
+
if p.help:
|
|
222
|
+
text += f": {_one_line(p.help)}"
|
|
223
|
+
return text
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _repeat_example(p: Param) -> str:
|
|
227
|
+
short = next((o for o in p.opts if len(o) == 2 and o[0] == "-" and o[1] != "-"), None)
|
|
228
|
+
if short:
|
|
229
|
+
return short + short[1]
|
|
230
|
+
opt = p.opts[0] if p.opts else p.name
|
|
231
|
+
return f"{opt} {opt}"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _option_names(p: Param) -> str:
|
|
235
|
+
return ", ".join(sorted(p.opts, key=len))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _option_sig(p: Param) -> str:
|
|
239
|
+
names = _option_names(p)
|
|
240
|
+
if p.secondary_opts:
|
|
241
|
+
names += " / " + ", ".join(sorted(p.secondary_opts, key=len))
|
|
242
|
+
if p.is_flag:
|
|
243
|
+
return names
|
|
244
|
+
return f"{names} {_value_sig(p)}"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _value_sig(p: Param) -> str:
|
|
248
|
+
if p.value_tokens:
|
|
249
|
+
return " ".join(p.value_tokens)
|
|
250
|
+
hint = _value_hint(p)
|
|
251
|
+
if p.nargs == "?" or p.optional_value:
|
|
252
|
+
return f"[{hint}]"
|
|
253
|
+
if p.nargs == "*":
|
|
254
|
+
return f"[{hint} ...]"
|
|
255
|
+
if p.nargs == "+":
|
|
256
|
+
return f"{hint} [{hint} ...]"
|
|
257
|
+
if p.nargs == "...":
|
|
258
|
+
return f"{hint}..."
|
|
259
|
+
return hint
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _value_hint(p: Param) -> str:
|
|
263
|
+
if p.choices:
|
|
264
|
+
return "[" + "|".join(p.choices) + "]"
|
|
265
|
+
if p.metavar:
|
|
266
|
+
return p.metavar
|
|
267
|
+
return p.type.upper().replace(" ", "_")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _argument_token(p: Param) -> str:
|
|
271
|
+
if p.value_tokens:
|
|
272
|
+
token = " ".join(p.value_tokens)
|
|
273
|
+
else:
|
|
274
|
+
token = p.metavar or p.name.upper()
|
|
275
|
+
if p.multiple:
|
|
276
|
+
token += "..."
|
|
277
|
+
return token if p.required else f"[{token}]"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _one_line(text: str) -> str:
|
|
281
|
+
return " ".join(text.split())
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _yaml_str(text: str) -> str:
|
|
285
|
+
escaped = text.replace("\\", "\\\\").replace('"', '\\"')
|
|
286
|
+
return f'"{escaped}"'
|