procclean 1.2.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.
- procclean/__init__.py +11 -0
- procclean/__main__.py +22 -0
- procclean/cli/__init__.py +27 -0
- procclean/cli/commands.py +213 -0
- procclean/cli/docs.py +234 -0
- procclean/cli/parser.py +272 -0
- procclean/core/__init__.py +47 -0
- procclean/core/actions.py +46 -0
- procclean/core/constants.py +48 -0
- procclean/core/filters.py +121 -0
- procclean/core/memory.py +22 -0
- procclean/core/models.py +27 -0
- procclean/core/process.py +160 -0
- procclean/formatters/__init__.py +33 -0
- procclean/formatters/columns.py +128 -0
- procclean/formatters/output.py +158 -0
- procclean/tui/__init__.py +6 -0
- procclean/tui/app.py +401 -0
- procclean/tui/app.tcss +87 -0
- procclean/tui/screens.py +79 -0
- procclean-1.2.0.dist-info/METADATA +164 -0
- procclean-1.2.0.dist-info/RECORD +24 -0
- procclean-1.2.0.dist-info/WHEEL +4 -0
- procclean-1.2.0.dist-info/entry_points.txt +3 -0
procclean/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Process cleanup TUI application."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
__version__ = version("procclean")
|
|
6
|
+
|
|
7
|
+
# Re-export main entry point and core types
|
|
8
|
+
from procclean.__main__ import main
|
|
9
|
+
from procclean.core import ProcessInfo
|
|
10
|
+
|
|
11
|
+
__all__ = ["ProcessInfo", "__version__", "main"]
|
procclean/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Entry point for procclean - runs as python -m procclean or via console script."""
|
|
2
|
+
|
|
3
|
+
from .cli import run_cli
|
|
4
|
+
from .tui import ProcessCleanerApp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main() -> None:
|
|
8
|
+
"""Dispatch to CLI or run TUI.
|
|
9
|
+
|
|
10
|
+
Raises:
|
|
11
|
+
SystemExit: When CLI command returns non-zero exit code.
|
|
12
|
+
"""
|
|
13
|
+
result = run_cli()
|
|
14
|
+
if result == -1:
|
|
15
|
+
# No subcommand - run TUI
|
|
16
|
+
ProcessCleanerApp().run()
|
|
17
|
+
else:
|
|
18
|
+
raise SystemExit(result)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""CLI interface for procclean."""
|
|
2
|
+
|
|
3
|
+
# Internal helpers - exported for testing
|
|
4
|
+
from .commands import (
|
|
5
|
+
_confirm_kill,
|
|
6
|
+
_do_preview,
|
|
7
|
+
_get_kill_targets,
|
|
8
|
+
cmd_groups,
|
|
9
|
+
cmd_kill,
|
|
10
|
+
cmd_list,
|
|
11
|
+
cmd_memory,
|
|
12
|
+
get_filtered_processes,
|
|
13
|
+
)
|
|
14
|
+
from .parser import create_parser, run_cli
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"_confirm_kill",
|
|
18
|
+
"_do_preview",
|
|
19
|
+
"_get_kill_targets",
|
|
20
|
+
"cmd_groups",
|
|
21
|
+
"cmd_kill",
|
|
22
|
+
"cmd_list",
|
|
23
|
+
"cmd_memory",
|
|
24
|
+
"create_parser",
|
|
25
|
+
"get_filtered_processes",
|
|
26
|
+
"run_cli",
|
|
27
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""CLI command handlers."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich import print # pylint: disable=redefined-builtin
|
|
9
|
+
|
|
10
|
+
from procclean.core import (
|
|
11
|
+
PREVIEW_LIMIT,
|
|
12
|
+
filter_by_cwd,
|
|
13
|
+
filter_high_memory,
|
|
14
|
+
filter_killable,
|
|
15
|
+
filter_orphans,
|
|
16
|
+
find_similar_processes,
|
|
17
|
+
get_memory_summary,
|
|
18
|
+
get_process_list,
|
|
19
|
+
kill_processes,
|
|
20
|
+
sort_processes,
|
|
21
|
+
)
|
|
22
|
+
from procclean.formatters import format_output
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
26
|
+
"""List processes command.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
int: Exit code (0 on success).
|
|
30
|
+
"""
|
|
31
|
+
procs = get_filtered_processes(args)
|
|
32
|
+
|
|
33
|
+
# Apply sorting
|
|
34
|
+
reverse = not args.ascending
|
|
35
|
+
procs = sort_processes(procs, sort_by=args.sort, reverse=reverse)
|
|
36
|
+
|
|
37
|
+
# Limit output
|
|
38
|
+
if args.limit:
|
|
39
|
+
procs = procs[: args.limit]
|
|
40
|
+
|
|
41
|
+
# Parse columns
|
|
42
|
+
columns = args.columns.split(",") if args.columns else None
|
|
43
|
+
|
|
44
|
+
print(format_output(procs, args.format, columns=columns))
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_groups(args: argparse.Namespace) -> int:
|
|
49
|
+
"""Show grouped processes command.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
int: Exit code (0 on success).
|
|
53
|
+
"""
|
|
54
|
+
procs = get_process_list(min_memory_mb=args.min_memory)
|
|
55
|
+
groups = find_similar_processes(procs)
|
|
56
|
+
|
|
57
|
+
if not groups:
|
|
58
|
+
print("No process groups found.")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if args.format == "json":
|
|
62
|
+
data = {
|
|
63
|
+
cmd: [
|
|
64
|
+
{"pid": p.pid, "name": p.name, "rss_mb": round(p.rss_mb, 2)}
|
|
65
|
+
for p in group_procs
|
|
66
|
+
]
|
|
67
|
+
for cmd, group_procs in groups.items()
|
|
68
|
+
}
|
|
69
|
+
print(json.dumps(data, indent=2))
|
|
70
|
+
else:
|
|
71
|
+
for cmd, group_procs in sorted(
|
|
72
|
+
groups.items(), key=lambda x: sum(p.rss_mb for p in x[1]), reverse=True
|
|
73
|
+
):
|
|
74
|
+
total_mb = sum(p.rss_mb for p in group_procs)
|
|
75
|
+
print(f"\n{cmd} ({len(group_procs)} processes, {total_mb:.1f} MB total)")
|
|
76
|
+
for p in sorted(group_procs, key=lambda x: x.rss_mb, reverse=True):
|
|
77
|
+
print(f" PID {p.pid}: {p.rss_mb:.1f} MB")
|
|
78
|
+
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_filtered_processes(args: argparse.Namespace) -> list:
|
|
83
|
+
"""Get processes with all filters from args applied.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
list: Filtered list of processes.
|
|
87
|
+
"""
|
|
88
|
+
procs = get_process_list(min_memory_mb=getattr(args, "min_memory", 5.0))
|
|
89
|
+
|
|
90
|
+
# Apply cwd filter
|
|
91
|
+
if getattr(args, "cwd", None) is not None:
|
|
92
|
+
cwd_path = args.cwd or str(Path.cwd())
|
|
93
|
+
procs = filter_by_cwd(procs, cwd_path)
|
|
94
|
+
|
|
95
|
+
# Apply preset filters
|
|
96
|
+
filt = getattr(args, "filter", None)
|
|
97
|
+
threshold = getattr(args, "high_memory_threshold", 500.0)
|
|
98
|
+
if filt == "killable" or getattr(args, "killable", False):
|
|
99
|
+
procs = filter_killable(procs)
|
|
100
|
+
elif filt == "orphans" or getattr(args, "orphans", False):
|
|
101
|
+
procs = filter_orphans(procs)
|
|
102
|
+
elif filt == "high-memory" or getattr(args, "high_memory", False):
|
|
103
|
+
procs = filter_high_memory(procs, threshold_mb=threshold)
|
|
104
|
+
|
|
105
|
+
return procs
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_kill_targets(args: argparse.Namespace) -> list:
|
|
109
|
+
"""Get target processes for kill command from PIDs or filters.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
list: Target processes to kill.
|
|
113
|
+
"""
|
|
114
|
+
if args.pids:
|
|
115
|
+
all_procs = get_process_list(min_memory_mb=0)
|
|
116
|
+
pid_set = set(args.pids)
|
|
117
|
+
procs = [p for p in all_procs if p.pid in pid_set]
|
|
118
|
+
found_pids = {p.pid for p in procs}
|
|
119
|
+
for pid in args.pids:
|
|
120
|
+
if pid not in found_pids:
|
|
121
|
+
print(f"Warning: PID {pid} not found")
|
|
122
|
+
return procs
|
|
123
|
+
return get_filtered_processes(args)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _do_preview(args: argparse.Namespace, procs: list) -> int:
|
|
127
|
+
"""Show preview of what would be killed.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
int: Exit code (0 on success).
|
|
131
|
+
"""
|
|
132
|
+
if hasattr(args, "sort") and args.sort:
|
|
133
|
+
procs = sort_processes(procs, sort_by=args.sort, reverse=True)
|
|
134
|
+
if hasattr(args, "limit") and args.limit:
|
|
135
|
+
procs = procs[: args.limit]
|
|
136
|
+
columns = args.columns.split(",") if getattr(args, "columns", None) else None
|
|
137
|
+
fmt = getattr(args, "out_format", "table")
|
|
138
|
+
print(format_output(procs, fmt, columns=columns))
|
|
139
|
+
print(f"\n{len(procs)} process(es) would be killed.")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _confirm_kill(args: argparse.Namespace, procs: list) -> bool:
|
|
144
|
+
"""Prompt for kill confirmation.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
args: Parsed CLI arguments.
|
|
148
|
+
procs: Processes that would be killed.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
True if the kill action is confirmed (or confirmation is skipped), otherwise
|
|
152
|
+
False.
|
|
153
|
+
"""
|
|
154
|
+
if args.yes or not sys.stdin.isatty():
|
|
155
|
+
return True
|
|
156
|
+
action = "FORCE KILL" if args.force else "terminate"
|
|
157
|
+
print(f"About to {action} {len(procs)} process(es):")
|
|
158
|
+
for p in procs[:PREVIEW_LIMIT]:
|
|
159
|
+
print(f" {p.pid}: {p.name} ({p.rss_mb:.1f} MB)")
|
|
160
|
+
if len(procs) > PREVIEW_LIMIT:
|
|
161
|
+
print(f" ... and {len(procs) - PREVIEW_LIMIT} more")
|
|
162
|
+
try:
|
|
163
|
+
response = input("Continue? [y/N] ")
|
|
164
|
+
return response.lower() in {"y", "yes"}
|
|
165
|
+
except EOFError:
|
|
166
|
+
return True # Non-interactive
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_kill(args: argparse.Namespace) -> int:
|
|
170
|
+
"""Kill processes command.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
int: Exit code (0 on success).
|
|
174
|
+
"""
|
|
175
|
+
procs = _get_kill_targets(args)
|
|
176
|
+
if not procs:
|
|
177
|
+
print("No processes match the filters.")
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
if getattr(args, "preview", False):
|
|
181
|
+
return _do_preview(args, procs)
|
|
182
|
+
|
|
183
|
+
if not _confirm_kill(args, procs):
|
|
184
|
+
print("Aborted.")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
results = kill_processes([p.pid for p in procs], force=args.force)
|
|
188
|
+
exit_code = 0
|
|
189
|
+
for _, success, msg in results:
|
|
190
|
+
status = "OK" if success else "FAILED"
|
|
191
|
+
print(f"[{status}] {msg}")
|
|
192
|
+
if not success:
|
|
193
|
+
exit_code = 1
|
|
194
|
+
return exit_code
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_memory(args: argparse.Namespace) -> int:
|
|
198
|
+
"""Show memory summary command.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
int: Exit code (0 on success).
|
|
202
|
+
"""
|
|
203
|
+
mem = get_memory_summary()
|
|
204
|
+
|
|
205
|
+
if args.format == "json":
|
|
206
|
+
print(json.dumps(mem, indent=2))
|
|
207
|
+
else:
|
|
208
|
+
print(f"Total: {mem['total_gb']:.2f} GB")
|
|
209
|
+
print(f"Used: {mem['used_gb']:.2f} GB ({mem['percent']:.1f}%)")
|
|
210
|
+
print(f"Free: {mem['free_gb']:.2f} GB")
|
|
211
|
+
print(f"Swap: {mem['swap_used_gb']:.2f} / {mem['swap_total_gb']:.2f} GB")
|
|
212
|
+
|
|
213
|
+
return 0
|
procclean/cli/docs.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""CLI documentation generation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from markdown_it import MarkdownIt
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from rich_argparse import RichHelpFormatter
|
|
16
|
+
|
|
17
|
+
from .parser import create_parser
|
|
18
|
+
|
|
19
|
+
DOCS_CLI_PATH = Path(__file__).parent.parent.parent.parent / "docs" / "cli.md"
|
|
20
|
+
HEADER_COMMENT = (
|
|
21
|
+
"<!-- AUTO-GENERATED - DO NOT EDIT -->\n"
|
|
22
|
+
"<!-- Run: ./scripts/generate_cli_docs.py -->\n"
|
|
23
|
+
"<!-- dprint-ignore-file -->\n"
|
|
24
|
+
"<!-- markdownlint-disable-file -->\n\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Apply custom styles for CLI help output (once at module load)
|
|
28
|
+
_HELP_STYLES = {
|
|
29
|
+
"prog": "#98f641",
|
|
30
|
+
"args": "#54ebdd",
|
|
31
|
+
"groups": "#98f641",
|
|
32
|
+
"metavar": "#7af0e5",
|
|
33
|
+
"syntax": "#98f641",
|
|
34
|
+
"help": "white",
|
|
35
|
+
"text": "white",
|
|
36
|
+
"default": "#888888",
|
|
37
|
+
}
|
|
38
|
+
for _key, _value in _HELP_STYLES.items():
|
|
39
|
+
RichHelpFormatter.styles[f"argparse.{_key}"] = _value
|
|
40
|
+
|
|
41
|
+
_PRE_STYLE = "font-family:Menlo,'DejaVu Sans Mono',consolas,'Courier New',monospace"
|
|
42
|
+
_CODE_FORMAT = (
|
|
43
|
+
f'<pre style="{_PRE_STYLE}">\n'
|
|
44
|
+
'<code style="font-family:inherit" class="nohighlight">{code}</code>\n'
|
|
45
|
+
"</pre>\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _capture_help(parser: argparse.ArgumentParser) -> str:
|
|
50
|
+
"""Capture the help text of an argparse parser as colored HTML.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
HTML string with colored help output.
|
|
54
|
+
"""
|
|
55
|
+
# Force colored output even when not in a TTY
|
|
56
|
+
old_force_color = os.environ.get("FORCE_COLOR")
|
|
57
|
+
os.environ["FORCE_COLOR"] = "1"
|
|
58
|
+
try:
|
|
59
|
+
parser.formatter_class = RichHelpFormatter
|
|
60
|
+
text = Text.from_ansi(parser.format_help())
|
|
61
|
+
finally:
|
|
62
|
+
if old_force_color is None:
|
|
63
|
+
os.environ.pop("FORCE_COLOR", None)
|
|
64
|
+
else:
|
|
65
|
+
os.environ["FORCE_COLOR"] = old_force_color
|
|
66
|
+
|
|
67
|
+
# force_terminal=True ensures colored output even when not in a TTY
|
|
68
|
+
console = Console(file=io.StringIO(), record=True, force_terminal=True)
|
|
69
|
+
console.print(text, crop=False)
|
|
70
|
+
|
|
71
|
+
return console.export_html(code_format=_CODE_FORMAT, inline_styles=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _argparser_to_markdown(
|
|
75
|
+
parser: argparse.ArgumentParser,
|
|
76
|
+
heading: str = "CLI Reference",
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Convert an argparse parser to markdown documentation with colored output.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
parser: The argparse parser to document.
|
|
82
|
+
heading: The main heading for the documentation.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Markdown string with colored HTML help blocks.
|
|
86
|
+
"""
|
|
87
|
+
prog = parser.prog
|
|
88
|
+
main_help = _capture_help(parser).rstrip()
|
|
89
|
+
|
|
90
|
+
lines = [
|
|
91
|
+
f"# {heading}",
|
|
92
|
+
"",
|
|
93
|
+
f"Documentation for the `{prog}` script.",
|
|
94
|
+
"",
|
|
95
|
+
"```console",
|
|
96
|
+
f"{prog} --help",
|
|
97
|
+
"```",
|
|
98
|
+
"",
|
|
99
|
+
main_help,
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
# Find subparsers
|
|
103
|
+
subparsers_actions = [
|
|
104
|
+
action
|
|
105
|
+
for action in parser._actions # noqa: SLF001
|
|
106
|
+
if isinstance(action, argparse._SubParsersAction) # noqa: SLF001
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
if subparsers_actions:
|
|
110
|
+
current_subparsers_action = subparsers_actions[0]
|
|
111
|
+
for sub_cmd_name, sub_cmd_parser in current_subparsers_action.choices.items():
|
|
112
|
+
sub_cmd_help_text = _capture_help(sub_cmd_parser).rstrip()
|
|
113
|
+
lines.extend([
|
|
114
|
+
"",
|
|
115
|
+
f"## {sub_cmd_name}",
|
|
116
|
+
"",
|
|
117
|
+
"```console",
|
|
118
|
+
f"{prog} {sub_cmd_name} --help",
|
|
119
|
+
"```",
|
|
120
|
+
"",
|
|
121
|
+
sub_cmd_help_text,
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_h2_sections(
|
|
128
|
+
markdown: str,
|
|
129
|
+
) -> tuple[list[str], list[tuple[str, int, int]]]:
|
|
130
|
+
"""Extract h2 sections from markdown using AST.
|
|
131
|
+
|
|
132
|
+
Uses markdown-it parser to reliably detect headings
|
|
133
|
+
(won't match ## inside fenced code blocks).
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Tuple of (lines, sections) where sections is list of (title, start, end).
|
|
137
|
+
"""
|
|
138
|
+
md = MarkdownIt()
|
|
139
|
+
tokens = md.parse(markdown)
|
|
140
|
+
lines = markdown.split("\n")
|
|
141
|
+
|
|
142
|
+
h2_starts: list[tuple[str, int]] = []
|
|
143
|
+
for i, token in enumerate(tokens):
|
|
144
|
+
if token.type == "heading_open" and token.tag == "h2":
|
|
145
|
+
title = tokens[i + 1].content
|
|
146
|
+
start_line = token.map[0] # type: ignore[index]
|
|
147
|
+
h2_starts.append((title, start_line))
|
|
148
|
+
|
|
149
|
+
h2_sections: list[tuple[str, int, int]] = []
|
|
150
|
+
for i, (title, start) in enumerate(h2_starts):
|
|
151
|
+
end = h2_starts[i + 1][1] if i + 1 < len(h2_starts) else len(lines)
|
|
152
|
+
h2_sections.append((title, start, end))
|
|
153
|
+
|
|
154
|
+
return lines, h2_sections
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _normalize_content(content: str) -> str:
|
|
158
|
+
"""Normalize content for comparison by removing command-specific parts.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Content with command invocations normalized and whitespace stripped.
|
|
162
|
+
"""
|
|
163
|
+
normalized = re.sub(r"procclean \w+ --help", "procclean CMD --help", content)
|
|
164
|
+
return normalized.strip()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def merge_alias_sections(markdown: str) -> str:
|
|
168
|
+
"""Merge duplicate argparse alias sections in CLI markdown.
|
|
169
|
+
|
|
170
|
+
Detects sections with identical help content (modulo command name)
|
|
171
|
+
and merges their headers, e.g., `## list` + `## ls` -> `## list / ls`.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Markdown with merged alias sections.
|
|
175
|
+
"""
|
|
176
|
+
lines, h2_sections = _extract_h2_sections(markdown)
|
|
177
|
+
if not h2_sections:
|
|
178
|
+
return markdown
|
|
179
|
+
|
|
180
|
+
# Group by normalized content
|
|
181
|
+
content_to_titles: dict[str, list[str]] = defaultdict(list)
|
|
182
|
+
for title, start, end in h2_sections:
|
|
183
|
+
content = "\n".join(lines[start + 1 : end])
|
|
184
|
+
normalized = _normalize_content(content)
|
|
185
|
+
content_to_titles[normalized].append(title)
|
|
186
|
+
|
|
187
|
+
# Rebuild with merged headers
|
|
188
|
+
result = lines[: h2_sections[0][1]][:]
|
|
189
|
+
seen: set[str] = set()
|
|
190
|
+
|
|
191
|
+
for _title, start, end in h2_sections:
|
|
192
|
+
content = "\n".join(lines[start + 1 : end])
|
|
193
|
+
normalized = _normalize_content(content)
|
|
194
|
+
|
|
195
|
+
if normalized in seen:
|
|
196
|
+
continue
|
|
197
|
+
seen.add(normalized)
|
|
198
|
+
|
|
199
|
+
aliases = content_to_titles[normalized]
|
|
200
|
+
header = " / ".join(f"`{a}`" for a in aliases)
|
|
201
|
+
|
|
202
|
+
result.append(f"## {header}")
|
|
203
|
+
result.extend(lines[start + 1 : end])
|
|
204
|
+
|
|
205
|
+
return "\n".join(result)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def generate_cli_docs() -> str:
|
|
209
|
+
"""Generate merged CLI documentation from argparse parser.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Complete CLI reference markdown with header comment.
|
|
213
|
+
"""
|
|
214
|
+
parser = create_parser()
|
|
215
|
+
markdown = _argparser_to_markdown(parser, heading="CLI Reference")
|
|
216
|
+
merged = merge_alias_sections(markdown)
|
|
217
|
+
return HEADER_COMMENT + merged
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def write_cli_docs() -> bool:
|
|
221
|
+
"""Generate and write CLI docs to docs/cli.md.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if file was updated, False if unchanged.
|
|
225
|
+
"""
|
|
226
|
+
new_content = generate_cli_docs()
|
|
227
|
+
|
|
228
|
+
if DOCS_CLI_PATH.exists():
|
|
229
|
+
existing = DOCS_CLI_PATH.read_text()
|
|
230
|
+
if existing == new_content:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
DOCS_CLI_PATH.write_text(new_content)
|
|
234
|
+
return True
|