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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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