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/__init__.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Make Python CLIs ready for LLM agents.
|
|
2
|
+
|
|
3
|
+
Generate an LLM-friendly reference (``--llms``) and an Agent Skills ``SKILL.md``
|
|
4
|
+
straight from a Click, Typer or argparse command tree, and catch drift between
|
|
5
|
+
that documentation and the real CLI in CI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ._argparse import from_argparse
|
|
16
|
+
from ._click import Resolver, default_resolve, from_click, is_click_command
|
|
17
|
+
from .model import Command, Param
|
|
18
|
+
from .privacy import find_machine_paths, redact_home
|
|
19
|
+
from .render import (
|
|
20
|
+
LLMS_MAX_CHARS,
|
|
21
|
+
SKILL_MAX_CHARS,
|
|
22
|
+
default_description,
|
|
23
|
+
render_index,
|
|
24
|
+
render_llms,
|
|
25
|
+
render_markdown,
|
|
26
|
+
render_skill,
|
|
27
|
+
usage,
|
|
28
|
+
)
|
|
29
|
+
from .sync import REFERENCE_FILE, Problem, Settings, check, read_settings, skill_parts, update_block
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Command",
|
|
33
|
+
"Param",
|
|
34
|
+
"Problem",
|
|
35
|
+
"Settings",
|
|
36
|
+
"LLMS_MAX_CHARS",
|
|
37
|
+
"REFERENCE_FILE",
|
|
38
|
+
"SKILL_MAX_CHARS",
|
|
39
|
+
"check",
|
|
40
|
+
"check_skill_file",
|
|
41
|
+
"default_description",
|
|
42
|
+
"default_resolve",
|
|
43
|
+
"find_machine_paths",
|
|
44
|
+
"introspect",
|
|
45
|
+
"redact_home",
|
|
46
|
+
"render_index",
|
|
47
|
+
"render_llms",
|
|
48
|
+
"render_markdown",
|
|
49
|
+
"read_settings",
|
|
50
|
+
"render_skill",
|
|
51
|
+
"update_block",
|
|
52
|
+
"usage",
|
|
53
|
+
"write_skill",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
__version__ = "0.1.0"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Names a framework falls back to when the real program name is unknown.
|
|
60
|
+
GENERIC_NAMES = frozenset(
|
|
61
|
+
{"cli", "main", "app", "run", "entry", "entrypoint", "command", "__main__", "__main__.py", "-c"}
|
|
62
|
+
)
|
|
63
|
+
UNNAMED = "__help2skill_unnamed__"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def introspect(
|
|
67
|
+
cli: Any,
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
resolve: Resolver | None = None,
|
|
71
|
+
hide_defaults: list[str] | None = None,
|
|
72
|
+
) -> Command:
|
|
73
|
+
"""Build a :class:`Command` tree from a Click command, Typer app or ArgumentParser.
|
|
74
|
+
|
|
75
|
+
``name`` is the program name users type (e.g. ``"mytool"``); pass it when the
|
|
76
|
+
framework cannot know it, as with Typer apps and ``__main__``-style parsers.
|
|
77
|
+
``resolve`` turns lazy placeholder commands into real ones (Click/Typer only);
|
|
78
|
+
see :func:`default_resolve`. ``hide_defaults`` lists parameters (by name or
|
|
79
|
+
option string) whose default is computed at run time and must not be written.
|
|
80
|
+
"""
|
|
81
|
+
cmd = _introspect(cli, name, resolve)
|
|
82
|
+
if hide_defaults:
|
|
83
|
+
_hide_defaults(cmd, hide_defaults)
|
|
84
|
+
return cmd
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _introspect(cli: Any, name: str | None, resolve: Resolver | None) -> Command:
|
|
88
|
+
if isinstance(cli, Command):
|
|
89
|
+
return cli
|
|
90
|
+
if isinstance(cli, argparse.ArgumentParser):
|
|
91
|
+
return from_argparse(cli, name)
|
|
92
|
+
if hasattr(cli, "registered_commands") and hasattr(cli, "registered_groups"):
|
|
93
|
+
import typer.main
|
|
94
|
+
|
|
95
|
+
cmd = typer.main.get_command(cli)
|
|
96
|
+
return from_click(cmd, name or getattr(cli.info, "name", None), resolve=resolve)
|
|
97
|
+
if is_click_command(cli):
|
|
98
|
+
return from_click(cli, name, resolve=resolve)
|
|
99
|
+
if callable(cli):
|
|
100
|
+
raise TypeError(
|
|
101
|
+
f"{getattr(cli, '__name__', cli)!r} is a function, not a CLI; if it builds the "
|
|
102
|
+
"parser, pass its result (e.g. build_parser()) instead"
|
|
103
|
+
)
|
|
104
|
+
raise TypeError(f"don't know how to introspect {type(cli).__name__}; expected Click, Typer or argparse")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _hide_defaults(cmd: Command, names: list[str]) -> None:
|
|
108
|
+
wanted = set(names)
|
|
109
|
+
found: set[str] = set()
|
|
110
|
+
for node in cmd.walk():
|
|
111
|
+
for p in node.params:
|
|
112
|
+
hits = wanted & {p.name, *p.opts, *p.secondary_opts}
|
|
113
|
+
if hits:
|
|
114
|
+
if p.default and p.help:
|
|
115
|
+
# argparse help often embeds the value: "(default: %(default)s)".
|
|
116
|
+
pattern = r"(default:?\s*)" + re.escape(p.default)
|
|
117
|
+
p.help = re.sub(pattern, r"\1computed at run time", p.help, flags=re.I)
|
|
118
|
+
p.default = None
|
|
119
|
+
found |= hits
|
|
120
|
+
missing = sorted(wanted - found)
|
|
121
|
+
if missing:
|
|
122
|
+
raise ValueError(f"--hide-default: no parameter named {', '.join(missing)}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def program_name(cli: Any, *candidates: str | None, resolve: Resolver | None = None) -> str | None:
|
|
126
|
+
"""First usable program name: the candidates in order, then the CLI's own name.
|
|
127
|
+
|
|
128
|
+
Generic names such as ``cli`` or ``main`` (a function name, not what users
|
|
129
|
+
type) are skipped. Returns ``None`` when nothing better is known.
|
|
130
|
+
"""
|
|
131
|
+
for candidate in candidates:
|
|
132
|
+
if candidate:
|
|
133
|
+
return candidate
|
|
134
|
+
own = _introspect(cli, None, resolve).name
|
|
135
|
+
return None if own in GENERIC_NAMES or own == UNNAMED else own
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def write_skill(
|
|
139
|
+
cli: Any,
|
|
140
|
+
path: str | Path,
|
|
141
|
+
*,
|
|
142
|
+
name: str | None = None,
|
|
143
|
+
description: str | None = None,
|
|
144
|
+
program: str | None = None,
|
|
145
|
+
max_chars: int | None = None,
|
|
146
|
+
resolve: Resolver | None = None,
|
|
147
|
+
hide_defaults: list[str] | None = None,
|
|
148
|
+
) -> Path:
|
|
149
|
+
"""Create ``path`` as a SKILL.md, or refresh only its generated block if it exists.
|
|
150
|
+
|
|
151
|
+
``name`` is the skill name (needed only to create the file). ``program`` is
|
|
152
|
+
the command users type; it defaults to the value stored in the file, then
|
|
153
|
+
the CLI's own name, then ``name``. When the full reference is longer than
|
|
154
|
+
``max_chars`` (default 30,000; 0 = never), SKILL.md gets a command index and
|
|
155
|
+
the full reference goes to ``references/commands.md`` next to it, which
|
|
156
|
+
agents read on demand. Your home directory is replaced by ``~``.
|
|
157
|
+
|
|
158
|
+
The settings used are stored in the block's begin marker, so a refresh or
|
|
159
|
+
a check needs none of them again.
|
|
160
|
+
"""
|
|
161
|
+
path = Path(path)
|
|
162
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else None
|
|
163
|
+
stored = read_settings(existing) if existing else Settings()
|
|
164
|
+
skill_name = name or (_skill_name(existing) if existing else None)
|
|
165
|
+
if not skill_name:
|
|
166
|
+
raise ValueError(f"{path} does not exist yet: pass a skill name to create it")
|
|
167
|
+
settings = Settings(
|
|
168
|
+
program=program_name(cli, program, stored.program, resolve=resolve) or skill_name,
|
|
169
|
+
max_chars=max_chars
|
|
170
|
+
if max_chars is not None
|
|
171
|
+
else (stored.max_chars if stored.max_chars is not None else SKILL_MAX_CHARS),
|
|
172
|
+
hide_defaults=hide_defaults if hide_defaults is not None else stored.hide_defaults,
|
|
173
|
+
)
|
|
174
|
+
cmd = introspect(cli, settings.program, resolve=resolve, hide_defaults=settings.hide_defaults)
|
|
175
|
+
assert settings.max_chars is not None
|
|
176
|
+
block, reference = skill_parts(cmd, max_chars=settings.max_chars)
|
|
177
|
+
if existing is not None:
|
|
178
|
+
text = update_block(existing, block, settings)
|
|
179
|
+
else:
|
|
180
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
text = render_skill(
|
|
182
|
+
cmd,
|
|
183
|
+
name=skill_name,
|
|
184
|
+
description=description or default_description(cmd),
|
|
185
|
+
block=block,
|
|
186
|
+
settings=settings,
|
|
187
|
+
)
|
|
188
|
+
path.write_text(text, encoding="utf-8")
|
|
189
|
+
reference_path = path.parent / REFERENCE_FILE
|
|
190
|
+
if reference is not None:
|
|
191
|
+
reference_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
reference_path.write_text(reference, encoding="utf-8")
|
|
193
|
+
elif reference_path.exists():
|
|
194
|
+
reference_path.unlink()
|
|
195
|
+
return path
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def check_skill_file(
|
|
199
|
+
cli: Any,
|
|
200
|
+
path: str | Path,
|
|
201
|
+
*,
|
|
202
|
+
program: str | None = None,
|
|
203
|
+
max_chars: int | None = None,
|
|
204
|
+
resolve: Resolver | None = None,
|
|
205
|
+
hide_defaults: list[str] | None = None,
|
|
206
|
+
) -> list[Problem]:
|
|
207
|
+
"""Return drift problems between the SKILL.md at ``path`` (and its reference file) and the CLI.
|
|
208
|
+
|
|
209
|
+
Settings not passed are read from the file's generated block.
|
|
210
|
+
"""
|
|
211
|
+
path = Path(path)
|
|
212
|
+
if not path.exists():
|
|
213
|
+
raise FileNotFoundError(
|
|
214
|
+
f"{path} not found (relative paths start from the current directory, {Path.cwd()})"
|
|
215
|
+
)
|
|
216
|
+
text = path.read_text(encoding="utf-8")
|
|
217
|
+
stored = read_settings(text)
|
|
218
|
+
reference_path = path.parent / REFERENCE_FILE
|
|
219
|
+
reference = reference_path.read_text(encoding="utf-8") if reference_path.exists() else None
|
|
220
|
+
prog = program_name(cli, program, stored.program, resolve=resolve) or _skill_name(text)
|
|
221
|
+
cmd = introspect(
|
|
222
|
+
cli,
|
|
223
|
+
prog,
|
|
224
|
+
resolve=resolve,
|
|
225
|
+
hide_defaults=hide_defaults if hide_defaults is not None else stored.hide_defaults,
|
|
226
|
+
)
|
|
227
|
+
limit = (
|
|
228
|
+
max_chars
|
|
229
|
+
if max_chars is not None
|
|
230
|
+
else (stored.max_chars if stored.max_chars is not None else SKILL_MAX_CHARS)
|
|
231
|
+
)
|
|
232
|
+
return check(text, cmd, max_chars=limit, reference=reference)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _skill_name(text: str) -> str | None:
|
|
236
|
+
if not text.startswith("---"):
|
|
237
|
+
return None
|
|
238
|
+
for line in text.splitlines()[1:]:
|
|
239
|
+
if line.strip() == "---":
|
|
240
|
+
break
|
|
241
|
+
if line.startswith("name:"):
|
|
242
|
+
return line.split(":", 1)[1].strip().strip("\"'") or None
|
|
243
|
+
return None
|
help2skill/__main__.py
ADDED
help2skill/_argparse.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Introspect ``argparse.ArgumentParser`` trees."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .model import Command, Param
|
|
9
|
+
|
|
10
|
+
_FLAG_ACTIONS = (
|
|
11
|
+
argparse._StoreTrueAction,
|
|
12
|
+
argparse._StoreFalseAction,
|
|
13
|
+
argparse._StoreConstAction,
|
|
14
|
+
argparse._CountAction,
|
|
15
|
+
argparse._AppendConstAction,
|
|
16
|
+
)
|
|
17
|
+
# The option itself may be repeated.
|
|
18
|
+
_REPEAT_ACTIONS = (
|
|
19
|
+
argparse._AppendAction,
|
|
20
|
+
argparse._ExtendAction,
|
|
21
|
+
argparse._AppendConstAction,
|
|
22
|
+
argparse._CountAction,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def from_argparse(parser: argparse.ArgumentParser, name: str | None = None) -> Command:
|
|
27
|
+
root_name = name or parser.prog
|
|
28
|
+
return _build(parser, root_name, [root_name], help_text=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build(parser: argparse.ArgumentParser, name: str, path: list[str], help_text: str | None) -> Command:
|
|
32
|
+
params: list[Param] = []
|
|
33
|
+
subcommands: list[Command] = []
|
|
34
|
+
aliases_of: dict[str, list[str]] = {}
|
|
35
|
+
for action in parser._actions:
|
|
36
|
+
if isinstance(action, argparse._HelpAction):
|
|
37
|
+
continue
|
|
38
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
39
|
+
helps = {a.dest: a.help for a in action._choices_actions}
|
|
40
|
+
primary: dict[int, str] = {}
|
|
41
|
+
for sub_name, sub_parser in action.choices.items():
|
|
42
|
+
if id(sub_parser) in primary: # aliases point at the same parser
|
|
43
|
+
aliases_of.setdefault(primary[id(sub_parser)], []).append(sub_name)
|
|
44
|
+
continue
|
|
45
|
+
primary[id(sub_parser)] = sub_name
|
|
46
|
+
subcommands.append(_build(sub_parser, sub_name, path + [sub_name], helps.get(sub_name)))
|
|
47
|
+
continue
|
|
48
|
+
if action.help == argparse.SUPPRESS:
|
|
49
|
+
continue
|
|
50
|
+
params.append(_param(action))
|
|
51
|
+
for sub in subcommands:
|
|
52
|
+
sub.aliases = aliases_of.get(sub.name, [])
|
|
53
|
+
description = parser.description or help_text
|
|
54
|
+
full_help = _clean(description)
|
|
55
|
+
if parser.epilog and parser.epilog != argparse.SUPPRESS:
|
|
56
|
+
epilog = parser.epilog.strip()
|
|
57
|
+
full_help = f"{full_help}\n\n{epilog}" if full_help else epilog
|
|
58
|
+
return Command(
|
|
59
|
+
name=name,
|
|
60
|
+
path=path,
|
|
61
|
+
help=full_help,
|
|
62
|
+
short_help=_clean(help_text) or _first_line(description),
|
|
63
|
+
params=params,
|
|
64
|
+
subcommands=subcommands,
|
|
65
|
+
exclusive=_exclusive_groups(parser),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _exclusive_groups(parser: argparse.ArgumentParser) -> list[tuple[list[str], bool]]:
|
|
70
|
+
groups = []
|
|
71
|
+
for group in parser._mutually_exclusive_groups:
|
|
72
|
+
names = [
|
|
73
|
+
max(a.option_strings, key=len) if a.option_strings else a.dest.upper()
|
|
74
|
+
for a in group._group_actions
|
|
75
|
+
if a.help != argparse.SUPPRESS
|
|
76
|
+
]
|
|
77
|
+
if len(names) > 1:
|
|
78
|
+
groups.append((names, bool(group.required)))
|
|
79
|
+
return groups
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _param(action: argparse.Action) -> Param:
|
|
83
|
+
is_option = bool(action.option_strings)
|
|
84
|
+
is_flag = isinstance(action, _FLAG_ACTIONS) or action.nargs == 0
|
|
85
|
+
nargs = action.nargs if action.nargs is not None else 1
|
|
86
|
+
if is_option:
|
|
87
|
+
multiple = isinstance(action, _REPEAT_ACTIONS)
|
|
88
|
+
required = action.required
|
|
89
|
+
else:
|
|
90
|
+
multiple = nargs in ("*", "+", argparse.REMAINDER) or (isinstance(nargs, int) and nargs > 1)
|
|
91
|
+
required = nargs not in ("?", "*", argparse.REMAINDER)
|
|
92
|
+
return Param(
|
|
93
|
+
name=action.dest,
|
|
94
|
+
kind="option" if is_option else "argument",
|
|
95
|
+
opts=list(action.option_strings) if is_option else [action.dest],
|
|
96
|
+
type="bool" if is_flag else _type_name(action.type),
|
|
97
|
+
choices=[str(c) for c in action.choices]
|
|
98
|
+
if action.choices and not _is_range(action.choices)
|
|
99
|
+
else None,
|
|
100
|
+
required=bool(required),
|
|
101
|
+
default=_default(action, is_flag),
|
|
102
|
+
is_flag=is_flag,
|
|
103
|
+
multiple=multiple,
|
|
104
|
+
nargs=nargs,
|
|
105
|
+
help=_clean(_expand_help(action)),
|
|
106
|
+
metavar=action.metavar if isinstance(action.metavar, str) else None,
|
|
107
|
+
value_tokens=None if is_flag else _value_tokens(action, nargs),
|
|
108
|
+
count=isinstance(action, argparse._CountAction),
|
|
109
|
+
range=_range_text(action.choices),
|
|
110
|
+
optional_value=is_option and nargs == "?",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _value_tokens(action: argparse.Action, nargs: Any) -> list[str] | None:
|
|
115
|
+
"""Tokens for options that take a fixed number (> 1) of values."""
|
|
116
|
+
if not action.option_strings or not isinstance(nargs, int) or nargs <= 1:
|
|
117
|
+
return None
|
|
118
|
+
if isinstance(action.metavar, tuple) and len(action.metavar) == nargs:
|
|
119
|
+
return list(action.metavar)
|
|
120
|
+
token = action.metavar if isinstance(action.metavar, str) else _type_name(action.type).upper()
|
|
121
|
+
return [token] * nargs
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_range(choices: Any) -> bool:
|
|
125
|
+
return isinstance(choices, range) and len(choices) > 20
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _range_text(choices: Any) -> str | None:
|
|
129
|
+
if not isinstance(choices, range) or not _is_range(choices) or choices.step != 1:
|
|
130
|
+
return None
|
|
131
|
+
return f"{choices.start}<=x<={choices.stop - 1}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _type_name(t: Any) -> str:
|
|
135
|
+
if t is None or t is str:
|
|
136
|
+
return "text"
|
|
137
|
+
name = getattr(t, "__name__", type(t).__name__)
|
|
138
|
+
return {"PurePath": "path", "Path": "path", "FileType": "file"}.get(name, name.lower())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _default(action: argparse.Action, is_flag: bool) -> str | None:
|
|
142
|
+
value = action.default
|
|
143
|
+
if value is None or value == argparse.SUPPRESS or value == "" or value == []:
|
|
144
|
+
return None
|
|
145
|
+
if is_flag and isinstance(
|
|
146
|
+
action, (argparse._StoreTrueAction, argparse._StoreFalseAction, argparse._CountAction)
|
|
147
|
+
):
|
|
148
|
+
return None
|
|
149
|
+
if isinstance(value, bool):
|
|
150
|
+
return "true" if value else "false"
|
|
151
|
+
if isinstance(value, (list, tuple)):
|
|
152
|
+
return ", ".join(str(v) for v in value)
|
|
153
|
+
return str(value)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _expand_help(action: argparse.Action) -> str | None:
|
|
157
|
+
text = action.help
|
|
158
|
+
if not text or "%" not in text:
|
|
159
|
+
return text
|
|
160
|
+
try:
|
|
161
|
+
return text % {**vars(action), "prog": "", "default": action.default}
|
|
162
|
+
except (KeyError, TypeError, ValueError):
|
|
163
|
+
return text
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _first_line(text: str | None) -> str | None:
|
|
167
|
+
if not text:
|
|
168
|
+
return None
|
|
169
|
+
return text.strip().split("\n\n")[0].replace("\n", " ") or None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _clean(text: str | None) -> str | None:
|
|
173
|
+
return text.strip() if text and text.strip() else None
|
help2skill/_click.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Introspect Click commands, including Typer's vendored Click (Typer >= 0.26).
|
|
2
|
+
|
|
3
|
+
Everything here is duck-typed: Typer's command classes do not subclass
|
|
4
|
+
``click.Command`` any more, so ``isinstance`` checks would silently miss them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import inspect
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .model import Command, Param
|
|
16
|
+
|
|
17
|
+
# Click marks deprecated commands in their help text; the renderer marks them itself.
|
|
18
|
+
_DEPRECATED_RE = re.compile(r"\s*\(deprecated(?::[^)]*)?\)\s*", re.I)
|
|
19
|
+
|
|
20
|
+
# Parameters that exist for the human shell, not for the agent.
|
|
21
|
+
INTERNAL_PARAMS = frozenset({"help", "install_completion", "show_completion", "llms"})
|
|
22
|
+
|
|
23
|
+
# Type names differ between Click versions ("integer range", "int_range"...);
|
|
24
|
+
# normalise them so the output is the same everywhere.
|
|
25
|
+
_TYPE_NAMES = {
|
|
26
|
+
"str": "text",
|
|
27
|
+
"string": "text",
|
|
28
|
+
"integer": "int",
|
|
29
|
+
"boolean": "bool",
|
|
30
|
+
"filename": "file",
|
|
31
|
+
"unprocessed": "text",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Resolver = Callable[[Any], Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def default_resolve(cmd: Any) -> Any:
|
|
39
|
+
"""Load placeholder commands that only build their real command on demand.
|
|
40
|
+
|
|
41
|
+
Covers placeholders with a ``materialize()`` method (Hugging Face's ``hf``).
|
|
42
|
+
Click's documented ``LazyGroup`` pattern needs nothing: it resolves in
|
|
43
|
+
``get_command``. Pass your own ``resolve`` for other patterns.
|
|
44
|
+
"""
|
|
45
|
+
materialize = getattr(cmd, "materialize", None)
|
|
46
|
+
return materialize() if callable(materialize) else cmd
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_click_command(obj: Any) -> bool:
|
|
50
|
+
return hasattr(obj, "params") and hasattr(obj, "make_context")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def from_click(
|
|
54
|
+
cmd: Any,
|
|
55
|
+
name: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
parent_ctx: Any = None,
|
|
58
|
+
resolve: Resolver | None = None,
|
|
59
|
+
) -> Command:
|
|
60
|
+
"""Build a :class:`Command` tree from a Click (or Typer-built) command."""
|
|
61
|
+
resolve = resolve or default_resolve
|
|
62
|
+
cmd = resolve(cmd)
|
|
63
|
+
info_name = name or cmd.name or "cli"
|
|
64
|
+
ctx = _make_context(cmd, info_name, parent_ctx)
|
|
65
|
+
path = (_ctx_path(parent_ctx) if parent_ctx is not None else []) + [info_name]
|
|
66
|
+
return _build(cmd, info_name, path, ctx, resolve)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def from_context(ctx: Any, resolve: Resolver | None = None) -> Command:
|
|
70
|
+
"""Build the tree for the command a live Click context is running."""
|
|
71
|
+
path = _ctx_path(ctx)
|
|
72
|
+
return _build(ctx.command, path[-1], path, ctx, resolve or default_resolve)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build(cmd: Any, name: str, path: list[str], ctx: Any, resolve: Resolver) -> Command:
|
|
76
|
+
subcommands = []
|
|
77
|
+
hidden = []
|
|
78
|
+
# Sorted: Click sorts, recent Typer keeps definition order; sorting keeps the
|
|
79
|
+
# output identical across versions.
|
|
80
|
+
for sub_name in sorted(_list_commands(cmd, ctx)):
|
|
81
|
+
sub = _get_command(cmd, ctx, sub_name)
|
|
82
|
+
if sub is None:
|
|
83
|
+
continue
|
|
84
|
+
if getattr(sub, "hidden", False):
|
|
85
|
+
hidden.append(sub_name)
|
|
86
|
+
continue
|
|
87
|
+
sub = resolve(sub)
|
|
88
|
+
sub_ctx = _make_context(sub, sub_name, ctx)
|
|
89
|
+
subcommands.append(_build(sub, sub_name, path + [sub_name], sub_ctx, resolve))
|
|
90
|
+
help_text = _clean(getattr(cmd, "help", None), strip_deprecated=True)
|
|
91
|
+
epilog = _clean(getattr(cmd, "epilog", None))
|
|
92
|
+
if epilog:
|
|
93
|
+
help_text = f"{help_text}\n\n{epilog}" if help_text else epilog
|
|
94
|
+
return Command(
|
|
95
|
+
name=name,
|
|
96
|
+
path=path,
|
|
97
|
+
help=help_text,
|
|
98
|
+
short_help=_short_help(cmd),
|
|
99
|
+
params=[_param(p) for p in cmd.params if _visible(p)],
|
|
100
|
+
subcommands=subcommands,
|
|
101
|
+
deprecated=bool(getattr(cmd, "deprecated", False)),
|
|
102
|
+
default_command=getattr(cmd, "default_cmd_name", None),
|
|
103
|
+
hidden_subcommands=hidden,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_context(cmd: Any, info_name: str, parent: Any) -> Any:
|
|
108
|
+
ctx_class = getattr(cmd, "context_class", None)
|
|
109
|
+
if ctx_class is None:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return ctx_class(cmd, info_name=info_name, parent=parent)
|
|
113
|
+
except Exception: # pragma: no cover - exotic custom contexts
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _ctx_path(ctx: Any) -> list[str]:
|
|
118
|
+
parts = []
|
|
119
|
+
while ctx is not None:
|
|
120
|
+
parts.append(ctx.info_name or ctx.command.name or "cli")
|
|
121
|
+
ctx = ctx.parent
|
|
122
|
+
return parts[::-1]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _list_commands(cmd: Any, ctx: Any) -> list[str]:
|
|
126
|
+
if hasattr(cmd, "list_commands") and ctx is not None:
|
|
127
|
+
return list(cmd.list_commands(ctx))
|
|
128
|
+
return list(getattr(cmd, "commands", {}) or {})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _get_command(cmd: Any, ctx: Any, name: str) -> Any:
|
|
132
|
+
if hasattr(cmd, "get_command") and ctx is not None:
|
|
133
|
+
return cmd.get_command(ctx, name)
|
|
134
|
+
return (getattr(cmd, "commands", {}) or {}).get(name)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _visible(p: Any) -> bool:
|
|
138
|
+
return not getattr(p, "hidden", False) and p.name not in INTERNAL_PARAMS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _param(p: Any) -> Param:
|
|
142
|
+
kind = "option" if p.param_type_name == "option" else "argument"
|
|
143
|
+
ptype = p.type
|
|
144
|
+
count = bool(getattr(p, "count", False))
|
|
145
|
+
is_flag = bool(getattr(p, "is_flag", False)) or count
|
|
146
|
+
envvar = getattr(p, "envvar", None)
|
|
147
|
+
nargs = p.nargs
|
|
148
|
+
value_tokens = None
|
|
149
|
+
if not is_flag:
|
|
150
|
+
if _is_tuple(ptype):
|
|
151
|
+
value_tokens = [_value_token(t) for t in ptype.types]
|
|
152
|
+
elif isinstance(nargs, int) and nargs > 1:
|
|
153
|
+
value_tokens = [_value_token(ptype)] * nargs
|
|
154
|
+
choices = getattr(ptype, "choices", None)
|
|
155
|
+
return Param(
|
|
156
|
+
name=p.name or "",
|
|
157
|
+
kind=kind,
|
|
158
|
+
opts=list(p.opts),
|
|
159
|
+
secondary_opts=list(getattr(p, "secondary_opts", []) or []),
|
|
160
|
+
type="bool" if is_flag and not count else type_name(ptype),
|
|
161
|
+
choices=[str(_plain(c)) for c in choices] if choices and not count else None,
|
|
162
|
+
required=bool(p.required),
|
|
163
|
+
default=None if count else _default(p, is_flag),
|
|
164
|
+
is_flag=is_flag,
|
|
165
|
+
multiple=bool(getattr(p, "multiple", False)) or nargs == -1 or count,
|
|
166
|
+
nargs=nargs,
|
|
167
|
+
envvars=[envvar] if isinstance(envvar, str) else list(envvar or []),
|
|
168
|
+
help=_clean(getattr(p, "help", None)),
|
|
169
|
+
metavar=p.metavar,
|
|
170
|
+
value_tokens=value_tokens,
|
|
171
|
+
count=count,
|
|
172
|
+
range=None if count else _range(ptype),
|
|
173
|
+
case_insensitive=choices is not None and getattr(ptype, "case_sensitive", True) is False,
|
|
174
|
+
prompts=bool(getattr(p, "prompt", None)),
|
|
175
|
+
optional_value=bool(getattr(p, "_flag_needs_value", False)),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def type_name(ptype: Any) -> str:
|
|
180
|
+
"""Stable, lowercase name of a Click parameter type."""
|
|
181
|
+
name = str(getattr(ptype, "name", None) or type(ptype).__name__).lower()
|
|
182
|
+
name = name.replace("_", " ")
|
|
183
|
+
if name.endswith(" range"):
|
|
184
|
+
name = name[: -len(" range")]
|
|
185
|
+
return _TYPE_NAMES.get(name, name).replace(" ", "_")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_tuple(ptype: Any) -> bool:
|
|
189
|
+
return isinstance(getattr(ptype, "types", None), (list, tuple)) and getattr(ptype, "is_composite", False)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _value_token(ptype: Any) -> str:
|
|
193
|
+
choices = getattr(ptype, "choices", None)
|
|
194
|
+
if choices:
|
|
195
|
+
return "[" + "|".join(str(_plain(c)) for c in choices) + "]"
|
|
196
|
+
return type_name(ptype).upper()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _range(ptype: Any) -> str | None:
|
|
200
|
+
if not hasattr(ptype, "min_open"):
|
|
201
|
+
return None
|
|
202
|
+
lo, hi = getattr(ptype, "min", None), getattr(ptype, "max", None)
|
|
203
|
+
lo_op = "<" if ptype.min_open else "<="
|
|
204
|
+
hi_op = "<" if ptype.max_open else "<="
|
|
205
|
+
if lo is not None and hi is not None:
|
|
206
|
+
return f"{lo}{lo_op}x{hi_op}{hi}"
|
|
207
|
+
if lo is not None:
|
|
208
|
+
return f"x{'>' if ptype.min_open else '>='}{lo}"
|
|
209
|
+
if hi is not None:
|
|
210
|
+
return f"x{hi_op}{hi}"
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _default(p: Any, is_flag: bool) -> str | None:
|
|
215
|
+
value = p.default
|
|
216
|
+
if value is None or callable(value) or type(value).__name__ == "Sentinel":
|
|
217
|
+
return None
|
|
218
|
+
if is_flag and value is False and not getattr(p, "secondary_opts", None):
|
|
219
|
+
return None
|
|
220
|
+
if isinstance(value, (list, tuple)):
|
|
221
|
+
if not value:
|
|
222
|
+
return None
|
|
223
|
+
return ", ".join(str(_plain(v)) for v in value)
|
|
224
|
+
if isinstance(value, bool):
|
|
225
|
+
return "true" if value else "false"
|
|
226
|
+
if value == "":
|
|
227
|
+
return None
|
|
228
|
+
return str(_plain(value))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _plain(value: Any) -> Any:
|
|
232
|
+
return value.value if isinstance(value, enum.Enum) else value
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _short_help(cmd: Any) -> str | None:
|
|
236
|
+
if getattr(cmd, "short_help", None):
|
|
237
|
+
return _clean(cmd.short_help, strip_deprecated=True)
|
|
238
|
+
getter = getattr(cmd, "get_short_help_str", None)
|
|
239
|
+
if getter is None:
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
text = getter(limit=200)
|
|
243
|
+
except TypeError: # pragma: no cover - very old Click
|
|
244
|
+
text = getter()
|
|
245
|
+
return _clean(text, strip_deprecated=True)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _clean(text: str | None, *, strip_deprecated: bool = False) -> str | None:
|
|
249
|
+
if not text:
|
|
250
|
+
return None
|
|
251
|
+
# Click's "\b" marks a paragraph that must not be rewrapped; drop it.
|
|
252
|
+
lines = [line for line in inspect.cleandoc(text).splitlines() if line.strip() != "\b"]
|
|
253
|
+
text = "\n".join(lines).strip()
|
|
254
|
+
if strip_deprecated:
|
|
255
|
+
text = _DEPRECATED_RE.sub(" ", text).strip()
|
|
256
|
+
return text or None
|