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/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ """The ``help2skill`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import difflib
7
+ import importlib
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import (
14
+ REFERENCE_FILE,
15
+ SKILL_MAX_CHARS,
16
+ UNNAMED,
17
+ __version__,
18
+ check_skill_file,
19
+ find_machine_paths,
20
+ introspect,
21
+ program_name,
22
+ read_settings,
23
+ redact_home,
24
+ render_llms,
25
+ write_skill,
26
+ )
27
+
28
+ _STALE_KINDS = ("stale-block", "missing-block", "stale-reference", "missing-reference")
29
+
30
+
31
+ class UsageError(Exception):
32
+ """A problem with the user's input: printed as one line, exit status 2."""
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(
37
+ prog="help2skill",
38
+ description="Generate LLM-ready docs and SKILL.md files from Click, Typer and argparse CLIs.",
39
+ epilog="TARGET is the import path of the CLI object, e.g. mypkg.cli:app. For a function "
40
+ "that builds an argparse parser, write mypkg.cli:build_parser() (with parentheses).",
41
+ )
42
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
43
+ sub = parser.add_subparsers(dest="action", required=True)
44
+
45
+ md = sub.add_parser("markdown", help="Print the Markdown reference for a CLI.")
46
+ md.add_argument("target", help="CLI object, e.g. mypkg.cli:app")
47
+ md.add_argument(
48
+ "--max-chars",
49
+ type=int,
50
+ default=0,
51
+ help="Print only an index when the reference is longer than this (default: 0 = no limit)",
52
+ )
53
+
54
+ skill = sub.add_parser("skill", help="Create a SKILL.md, or refresh its generated block.")
55
+ skill.add_argument("target", help="CLI object, e.g. mypkg.cli:app")
56
+ skill.add_argument(
57
+ "--name", help="Skill name: lowercase letters, digits, hyphens (needed to create the file)"
58
+ )
59
+ skill.add_argument("--description", help="Skill description (default: from the CLI help)")
60
+ skill.add_argument(
61
+ "--output",
62
+ "-o",
63
+ type=Path,
64
+ help="SKILL.md path (default: skills/<name>/SKILL.md)",
65
+ )
66
+ skill.add_argument(
67
+ "--max-chars",
68
+ type=int,
69
+ help=f"Above this size, keep an index in SKILL.md and the full reference in {REFERENCE_FILE} "
70
+ f"(default: {SKILL_MAX_CHARS}, or the value stored in the file; 0 = never split)",
71
+ )
72
+
73
+ chk = sub.add_parser("check", help="Exit 1 if a SKILL.md has drifted from the CLI.")
74
+ chk.add_argument("target", help="CLI object, e.g. mypkg.cli:app")
75
+ chk.add_argument("path", type=Path, help="SKILL.md to check")
76
+ chk.add_argument("--max-chars", type=int, help="Override the value stored in the file")
77
+ for p in (md, skill, chk):
78
+ p.add_argument(
79
+ "--program",
80
+ help="Program name users type (default: stored in the file, else the console script "
81
+ "that points at TARGET, else the CLI's own name)",
82
+ )
83
+ p.add_argument(
84
+ "--hide-default",
85
+ action="append",
86
+ metavar="NAME",
87
+ dest="hide_defaults",
88
+ help="Leave out the default of this parameter (name or option), e.g. one computed at "
89
+ "run time; repeatable. Stored in the file.",
90
+ )
91
+ p.add_argument(
92
+ "--app-dir",
93
+ metavar="DIR",
94
+ help="Import TARGET from this directory, e.g. src (default: the current directory)",
95
+ )
96
+ p.add_argument(
97
+ "--resolve",
98
+ metavar="MODULE:FUNC",
99
+ help="Function that turns a lazy placeholder command into the real command",
100
+ )
101
+ return parser
102
+
103
+
104
+ def load_target(spec: str, *, call: bool = True, app_dir: str | None = None) -> Any:
105
+ """Import ``module:attr``; ``module:func()`` calls ``func`` (e.g. a parser factory)."""
106
+ module_name, _, attr = spec.partition(":")
107
+ if not module_name or not attr or "/" in module_name or module_name.endswith(".py"):
108
+ raise UsageError(f"target must look like module.path:attribute, got {spec!r}")
109
+ explicit_call = attr.endswith("()")
110
+ attr = attr.removesuffix("()")
111
+ for directory in (os.getcwd(), app_dir):
112
+ if directory and os.path.abspath(directory) not in sys.path:
113
+ sys.path.insert(0, os.path.abspath(directory))
114
+ # argparse names an unnamed parser after sys.argv[0]; make that detectable.
115
+ saved_argv = sys.argv
116
+ sys.argv = [UNNAMED, *sys.argv[1:]]
117
+ try:
118
+ try:
119
+ obj: Any = importlib.import_module(module_name)
120
+ except ModuleNotFoundError as exc:
121
+ raise UsageError(
122
+ f"cannot import {module_name!r}: {exc}{_src_hint(module_name, app_dir)}"
123
+ ) from exc
124
+ for part in attr.split("."):
125
+ if not hasattr(obj, part):
126
+ names = [n for n in dir(obj) if not n.startswith("_")]
127
+ close = difflib.get_close_matches(part, names, n=1)
128
+ hint = f"; did you mean {close[0]!r}?" if close else ""
129
+ raise UsageError(f"{module_name!r} has no attribute {attr!r}{hint}")
130
+ obj = getattr(obj, part)
131
+ if call and explicit_call:
132
+ obj = obj()
133
+ elif call and callable(obj) and not _is_cli_object(obj):
134
+ if "parser" not in attr.rsplit(".", 1)[-1].lower():
135
+ raise UsageError(
136
+ f"{spec} is a function, not a Click command, Typer app or ArgumentParser. "
137
+ f"If it returns a parser, write {spec}() to call it."
138
+ )
139
+ obj = obj()
140
+ except SystemExit as exc:
141
+ raise UsageError(f"importing {spec} exited (status {exc.code}); point at the CLI object") from exc
142
+ finally:
143
+ sys.argv = saved_argv
144
+ return obj
145
+
146
+
147
+ def _src_hint(module_name: str, app_dir: str | None) -> str:
148
+ top = module_name.split(".")[0]
149
+ if app_dir is None and (Path("src") / top).exists():
150
+ return "; the package is under src/: add --app-dir src or install the project (pip install -e .)"
151
+ return ""
152
+
153
+
154
+ def _is_cli_object(obj: Any) -> bool:
155
+ return (
156
+ isinstance(obj, argparse.ArgumentParser)
157
+ or hasattr(obj, "registered_commands")
158
+ or (hasattr(obj, "params") and hasattr(obj, "make_context"))
159
+ )
160
+
161
+
162
+ def entry_point_name(spec: str) -> str | None:
163
+ """Name of the installed console script that runs ``spec`` (``module:attr``), if any."""
164
+ from importlib.metadata import entry_points
165
+
166
+ target = spec.removesuffix("()").replace(" ", "")
167
+ for ep in entry_points(group="console_scripts"):
168
+ if ep.value.replace(" ", "") == target:
169
+ return ep.name
170
+ return None
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> int:
174
+ args = build_parser().parse_args(argv)
175
+ try:
176
+ return _run(args)
177
+ except UsageError as exc:
178
+ print(f"help2skill: error: {exc}", file=sys.stderr)
179
+ return 2
180
+ except (ValueError, TypeError, FileNotFoundError) as exc:
181
+ print(f"help2skill: error: {exc}", file=sys.stderr)
182
+ return 2
183
+
184
+
185
+ def _run(args: argparse.Namespace) -> int:
186
+ cli = load_target(args.target, app_dir=args.app_dir)
187
+ resolve = load_target(args.resolve, call=False, app_dir=args.app_dir) if args.resolve else None
188
+ stored = None
189
+ if args.action == "skill" and args.output and args.output.exists():
190
+ stored = read_settings(args.output.read_text(encoding="utf-8")).program
191
+ if args.action == "check" and args.path.exists():
192
+ stored = read_settings(args.path.read_text(encoding="utf-8")).program
193
+ program = program_name(cli, args.program, stored, entry_point_name(args.target), resolve=resolve)
194
+
195
+ if args.action == "markdown":
196
+ if program is None:
197
+ _warn_unnamed(introspect(cli, resolve=resolve).name)
198
+ cmd = introspect(cli, program, resolve=resolve, hide_defaults=args.hide_defaults)
199
+ text = redact_home(render_llms(cmd, max_chars=args.max_chars))
200
+ _warn_machine_paths(text, "output")
201
+ sys.stdout.write(text)
202
+ return 0
203
+
204
+ if args.action == "skill":
205
+ if args.output is None and not args.name:
206
+ raise UsageError("pass --name (to create skills/<name>/SKILL.md) or --output PATH")
207
+ output = args.output or Path("skills") / args.name / "SKILL.md"
208
+ path = write_skill(
209
+ cli,
210
+ output,
211
+ name=args.name,
212
+ description=args.description,
213
+ program=program,
214
+ max_chars=args.max_chars,
215
+ resolve=resolve,
216
+ hide_defaults=args.hide_defaults,
217
+ )
218
+ print(f"wrote {path}")
219
+ reference = path.parent / REFERENCE_FILE
220
+ if reference.exists():
221
+ print(f"wrote {reference}")
222
+ for written in (path, reference):
223
+ if written.exists():
224
+ _warn_machine_paths(written.read_text(encoding="utf-8"), str(written))
225
+ return 0
226
+
227
+ problems = check_skill_file(
228
+ cli,
229
+ args.path,
230
+ program=program,
231
+ max_chars=args.max_chars,
232
+ resolve=resolve,
233
+ hide_defaults=args.hide_defaults,
234
+ )
235
+ for problem in problems:
236
+ print(f"{args.path}: {problem}", file=sys.stderr)
237
+ if any(p.kind in _STALE_KINDS for p in problems):
238
+ print(f"help2skill: regenerate with: help2skill skill {args.target} -o {args.path}", file=sys.stderr)
239
+ if problems:
240
+ return 1
241
+ print(f"{args.path}: in sync with {args.target}")
242
+ return 0
243
+
244
+
245
+ def _warn_unnamed(name: str) -> None:
246
+ shown = "the script name" if name == UNNAMED else repr(name)
247
+ print(
248
+ f"help2skill: warning: program name unknown (the CLI calls itself {shown}); "
249
+ "pass --program NAME or install the console script",
250
+ file=sys.stderr,
251
+ )
252
+
253
+
254
+ def _warn_machine_paths(text: str, where: str) -> None:
255
+ for found in find_machine_paths(text):
256
+ print(
257
+ f"help2skill: warning: {where} contains a machine-specific path {found!r}; "
258
+ "review it before committing",
259
+ file=sys.stderr,
260
+ )
261
+
262
+
263
+ if __name__ == "__main__": # pragma: no cover
264
+ raise SystemExit(main())
help2skill/click.py ADDED
@@ -0,0 +1,98 @@
1
+ """Click integration: a ``--llms`` flag that prints the Markdown reference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from typing import Any
8
+
9
+ # No top-level ``import click``: Typer >= 0.26 vendors Click, so a Typer app may run
10
+ # without the ``click`` package installed.
11
+ from ._click import from_context
12
+ from .render import LLMS_MAX_CHARS, render_llms
13
+
14
+ LLMS_HELP = "Print a Markdown reference of this command for LLM agents, then exit."
15
+ MAX_CHARS_ENV = "HELP2SKILL_LLMS_MAX_CHARS"
16
+
17
+
18
+ def print_llms(ctx: Any, param: Any, value: bool) -> None:
19
+ """Eager option callback usable with Click and Typer-built commands."""
20
+ if not value or ctx.resilient_parsing:
21
+ return
22
+ text = render_llms(
23
+ from_context(ctx), max_chars=_max_chars(), subcommand_flag=_subcommands_have_llms(ctx.command)
24
+ )
25
+ sys.stdout.write(text)
26
+ sys.stdout.flush()
27
+ ctx.exit()
28
+
29
+
30
+ def _subcommands_have_llms(cmd: Any) -> bool:
31
+ subs = list((getattr(cmd, "commands", None) or {}).values())
32
+ return bool(subs) and all(any(getattr(p, "name", None) == "llms" for p in s.params) for s in subs)
33
+
34
+
35
+ def _max_chars() -> int:
36
+ """Output budget for ``--llms``; set the env var to 0 for the full reference."""
37
+ try:
38
+ return int(os.environ.get(MAX_CHARS_ENV, LLMS_MAX_CHARS))
39
+ except ValueError:
40
+ return LLMS_MAX_CHARS
41
+
42
+
43
+ def llms_option(*param_decls: str, **kwargs: Any):
44
+ """Decorator adding ``--llms`` to one Click command or group.
45
+
46
+ On a group it prints the whole subtree, so putting it on the root is enough
47
+ for most tools. Use :func:`add_llms_option` to add it at every level.
48
+ """
49
+ import click
50
+
51
+ kwargs.setdefault("help", LLMS_HELP)
52
+ return click.option(
53
+ *(param_decls or ("--llms",)),
54
+ is_flag=True,
55
+ expose_value=False,
56
+ is_eager=True,
57
+ callback=print_llms,
58
+ **kwargs,
59
+ )
60
+
61
+
62
+ def add_llms_option(cmd: Any, *, recursive: bool = True) -> Any:
63
+ """Append ``--llms`` to an already-built command (and its subcommands).
64
+
65
+ Works for Typer-built commands too, whose classes come from Typer's
66
+ vendored copy of Click.
67
+ """
68
+ option_cls = _option_class(cmd)
69
+ for node in _iter_commands(cmd) if recursive else [cmd]:
70
+ if any(getattr(p, "name", None) == "llms" for p in node.params):
71
+ continue
72
+ node.params.append(
73
+ option_cls(
74
+ param_decls=["--llms"],
75
+ is_flag=True,
76
+ expose_value=False,
77
+ is_eager=True,
78
+ callback=print_llms,
79
+ help=LLMS_HELP,
80
+ )
81
+ )
82
+ return cmd
83
+
84
+
85
+ def _option_class(cmd: Any) -> Any:
86
+ if type(cmd).__module__.startswith("typer"):
87
+ from typer.core import TyperOption
88
+
89
+ return TyperOption
90
+ import click
91
+
92
+ return click.Option
93
+
94
+
95
+ def _iter_commands(cmd: Any):
96
+ yield cmd
97
+ for sub in (getattr(cmd, "commands", None) or {}).values():
98
+ yield from _iter_commands(sub)
help2skill/model.py ADDED
@@ -0,0 +1,89 @@
1
+ """Framework-neutral description of a command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class Param:
11
+ """One option or positional argument."""
12
+
13
+ name: str
14
+ kind: str # "option" or "argument"
15
+ opts: list[str] = field(default_factory=list)
16
+ secondary_opts: list[str] = field(default_factory=list)
17
+ type: str = "text"
18
+ choices: list[str] | None = None
19
+ required: bool = False
20
+ default: str | None = None
21
+ is_flag: bool = False
22
+ multiple: bool = False
23
+ nargs: int | str = 1
24
+ envvars: list[str] = field(default_factory=list)
25
+ help: str | None = None
26
+ metavar: str | None = None
27
+ # One token per value slot, e.g. ["NAME", "[integer|text]"] for a 2-tuple option.
28
+ value_tokens: list[str] | None = None
29
+ count: bool = False # -v, -vv, -vvv
30
+ range: str | None = None # e.g. "1<=x<=50"
31
+ case_insensitive: bool = False
32
+ prompts: bool = False # asks interactively when omitted
33
+ optional_value: bool = False # option may be given without its value
34
+
35
+ @property
36
+ def is_option(self) -> bool:
37
+ return self.kind == "option"
38
+
39
+
40
+ @dataclass
41
+ class Command:
42
+ """A command or command group, with its full invocation path."""
43
+
44
+ name: str
45
+ path: list[str]
46
+ help: str | None = None
47
+ short_help: str | None = None
48
+ params: list[Param] = field(default_factory=list)
49
+ subcommands: list[Command] = field(default_factory=list)
50
+ deprecated: bool = False
51
+ # Subcommand run when the first token is not a subcommand name (click-default-group).
52
+ default_command: str | None = None
53
+ aliases: list[str] = field(default_factory=list)
54
+ # Names that run a command but are not documented (hidden commands).
55
+ hidden_subcommands: list[str] = field(default_factory=list)
56
+ # Groups of options that cannot be combined: (option names, one is required).
57
+ exclusive: list[tuple[list[str], bool]] = field(default_factory=list)
58
+
59
+ @property
60
+ def is_group(self) -> bool:
61
+ return bool(self.subcommands)
62
+
63
+ @property
64
+ def options(self) -> list[Param]:
65
+ return [p for p in self.params if p.is_option]
66
+
67
+ @property
68
+ def arguments(self) -> list[Param]:
69
+ return [p for p in self.params if not p.is_option]
70
+
71
+ def walk(self) -> Iterator[Command]:
72
+ """Yield this command and every descendant, depth first."""
73
+ yield self
74
+ for sub in self.subcommands:
75
+ yield from sub.walk()
76
+
77
+ def get(self, name: str) -> Command | None:
78
+ """Find a direct subcommand by name or alias."""
79
+ for sub in self.subcommands:
80
+ if sub.name == name or name in sub.aliases:
81
+ return sub
82
+ return None
83
+
84
+ def find(self, path: list[str]) -> Command | None:
85
+ """Resolve a list of subcommand names below this command."""
86
+ node: Command | None = self
87
+ for part in path:
88
+ node = node.get(part) if node else None
89
+ return node
help2skill/privacy.py ADDED
@@ -0,0 +1,33 @@
1
+ """Keep machine-specific paths out of generated files.
2
+
3
+ Help text is sometimes computed at runtime (``default: /Users/alice/.cache/...``).
4
+ Written files replace the current user's home directory with ``~``, which also keeps
5
+ the drift check stable between a laptop and CI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ _MACHINE_PATH_RE = re.compile(r"(/Users/[^/\s`'\"]+|/home/[^/\s`'\"]+|[A-Za-z]:\\Users\\[^\\\s`'\"]+)")
15
+
16
+
17
+ def _home_variants(home: str | None) -> list[str]:
18
+ home = home or str(Path.home())
19
+ variants = {home, os.path.realpath(home)}
20
+ return sorted((v.rstrip("/\\") for v in variants if len(v) > 1), key=len, reverse=True)
21
+
22
+
23
+ def redact_home(text: str, home: str | None = None) -> str:
24
+ """Replace the home directory (``home`` or the current user's) with ``~``."""
25
+ for variant in _home_variants(home):
26
+ # Not followed by a name character, so /home/alice does not match /home/alicex.
27
+ text = re.sub(re.escape(variant) + r"(?![\w-])", "~", text)
28
+ return text
29
+
30
+
31
+ def find_machine_paths(text: str) -> list[str]:
32
+ """Return home-directory-like paths left in ``text``, e.g. another user's."""
33
+ return sorted(set(_MACHINE_PATH_RE.findall(text)))
help2skill/py.typed ADDED
File without changes