From e373615f2755c3f20d7205f830dfcd7e122716e9 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 23:17:48 -0500 Subject: [PATCH 1/4] fix(cli): restore detailed subcommand help --- graphify/__main__.py | 20 ++++++++++++- tests/test_cli_help.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_help.py diff --git a/graphify/__main__.py b/graphify/__main__.py index 155501a98d..6ec7cd5f14 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -479,6 +479,19 @@ def main() -> None: raise +def _has_detailed_help_handler(cmd: str, args: list[str]) -> bool: + """Return whether this exact invocation has a side-effect-free help handler.""" + help_flags = {"-h", "--help"} + if len(args) == 1 and args[0] in help_flags: + return cmd in {"prs", "reflect", "tree"} + return ( + cmd == "export" + and len(args) == 2 + and args[0] == "callflow-html" + and args[1] in help_flags + ) + + def _run_cli() -> None: for _stream in (sys.stdout, sys.stderr): if _stream is not None and hasattr(_stream, "reconfigure"): @@ -702,7 +715,12 @@ def _run_cli() -> None: # Exempt: free-text commands (user string may contain these tokens), and # "install"/"uninstall" which have their own per-subcommand help handlers. _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"} - if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]): + _command_args = sys.argv[2:] + if ( + cmd not in _FREE_TEXT_CMDS + and not _has_detailed_help_handler(cmd, _command_args) + and any(a in {"-h", "--help", "-?"} for a in _command_args) + ): print(f"Run 'graphify --help' for full usage.") return diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py new file mode 100644 index 0000000000..05fd2bbf42 --- /dev/null +++ b/tests/test_cli_help.py @@ -0,0 +1,67 @@ +"""CLI help routing keeps detailed handlers reachable without weakening the guard.""" + +from __future__ import annotations + +import sys +from unittest.mock import patch + +import pytest + + +def _invoke_main(monkeypatch, capsys, tmp_path, args: list[str]): + from graphify.__main__ import main + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", *args]) + with patch("graphify.__main__._check_skill_version"): + try: + main() + except SystemExit as exc: + assert exc.code in (0, None) + return capsys.readouterr() + + +@pytest.mark.parametrize( + ("args", "expected"), + [ + (["tree", "--help"], "Usage: graphify tree"), + (["tree", "-h"], "Usage: graphify tree"), + (["reflect", "--help"], "usage: graphify reflect"), + (["reflect", "-h"], "usage: graphify reflect"), + (["export", "callflow-html", "--help"], "Usage: graphify export callflow-html"), + (["export", "callflow-html", "-h"], "Usage: graphify export callflow-html"), + (["prs", "--help"], "graphify prs — graph-aware PR dashboard"), + (["prs", "-h"], "graphify prs — graph-aware PR dashboard"), + ], +) +def test_exact_safe_help_reaches_detailed_handler( + tmp_path, monkeypatch, capsys, args, expected +): + captured = _invoke_main(monkeypatch, capsys, tmp_path, args) + + assert expected in captured.out + assert "Run 'graphify --help'" not in captured.out + assert captured.err == "" + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize( + "args", + [ + ["tree", "--output", "--help"], + ["reflect", "--out", "--help"], + ["export", "callflow-html", "--output", "--help"], + ["prs", "--repo", "--help"], + ["tree", "-?"], + ["benchmark", "--help"], + ["export", "html", "--help"], + ], +) +def test_other_help_shapes_remain_behind_universal_guard( + tmp_path, monkeypatch, capsys, args +): + captured = _invoke_main(monkeypatch, capsys, tmp_path, args) + + assert captured.out == "Run 'graphify --help' for full usage.\n" + assert captured.err == "" + assert list(tmp_path.iterdir()) == [] From ed0e64e833f2a55a83dcfae1a8104eae118ddd3c Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 23:41:00 -0500 Subject: [PATCH 2/4] fix(cli): handle help before option parsing --- graphify/__main__.py | 10 +++++----- graphify/cli.py | 43 ++++++++++++++++++++++-------------------- graphify/prs.py | 7 ++++--- tests/test_cli_help.py | 20 +++++++++++++++----- 4 files changed, 47 insertions(+), 33 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 6ec7cd5f14..14fde9160a 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -480,15 +480,15 @@ def main() -> None: def _has_detailed_help_handler(cmd: str, args: list[str]) -> bool: - """Return whether this exact invocation has a side-effect-free help handler.""" + """Return whether this invocation has a side-effect-free help handler.""" help_flags = {"-h", "--help"} - if len(args) == 1 and args[0] in help_flags: - return cmd in {"prs", "reflect", "tree"} + if cmd in {"prs", "reflect", "tree"}: + return any(arg in help_flags for arg in args) return ( cmd == "export" - and len(args) == 2 + and len(args) >= 2 and args[0] == "callflow-html" - and args[1] in help_flags + and any(arg in help_flags for arg in args[1:]) ) diff --git a/graphify/cli.py b/graphify/cli.py index 02e55b9447..e8f74e1092 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1261,6 +1261,9 @@ def dispatch_command(cmd: str) -> None: p.add_argument("--if-stale", action="store_true", help="skip when LESSONS.md is already newer than every input " "(e.g. the git hook just refreshed it)") + if any(arg in {"-h", "--help"} for arg in sys.argv[2:]): + p.print_help() + return opts = p.parse_args(sys.argv[2:]) from graphify.reflect import reflect as _reflect, lessons_fresh as _lessons_fresh @@ -2246,6 +2249,15 @@ def _clear_html_stale_marker() -> None: top_k_edges = 0 project_label: "_Opt[str]" = None args = sys.argv[2:] + if any(arg in ("-h", "--help") for arg in args): + print("Usage: graphify tree [--graph PATH] [--output HTML]") + print(" --graph PATH path to graph.json (default graphify-out/graph.json)") + print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") + print(" --root PATH filesystem root (default: longest common dir of all source_files)") + print(" --max-children N cap visible children per node (default 200)") + print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)") + print(" --label NAME project label shown in the page header") + return i_arg = 0 while i_arg < len(args): a = args[i_arg] @@ -2261,15 +2273,6 @@ def _clear_html_stale_marker() -> None: top_k_edges = int(args[i_arg + 1]); i_arg += 2 elif a == "--label" and i_arg + 1 < len(args): project_label = args[i_arg + 1]; i_arg += 2 - elif a in ("-h", "--help"): - print("Usage: graphify tree [--graph PATH] [--output HTML]") - print(" --graph PATH path to graph.json (default graphify-out/graph.json)") - print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)") - print(" --root PATH filesystem root (default: longest common dir of all source_files)") - print(" --max-children N cap visible children per node (default 200)") - print(" --top-k-edges N pre-compute top-K outbound edges per symbol (default 12)") - print(" --label NAME project label shown in the page header") - return else: i_arg += 1 if not graph_path.is_file(): @@ -2517,6 +2520,17 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": # Parse shared args args = sys.argv[3:] + if subcmd == "callflow-html" and any(a in ("-h", "--help") for a in args): + print("Usage: graphify export callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH]") + print(" --report PATH path to GRAPH_REPORT.md") + print(" --sections PATH JSON section definitions") + print(" --output HTML output path (default graphify-out/-callflow.html)") + print(" --lang LANG auto, zh-CN, en, etc. (default auto)") + print(" --max-sections N maximum auto-derived sections (default 15)") + print(" --diagram-scale N Mermaid diagram scale (default 1.0)") + print(" --max-diagram-nodes N representative nodes per section (default 18)") + print(" --max-diagram-edges N representative edges per section (default 24)") + sys.exit(0) graph_path = Path(_GRAPHIFY_OUT) / "graph.json" graph_path_explicit = False labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json" @@ -2578,17 +2592,6 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": callflow_max_diagram_nodes = int(args[i + 1]); i += 2 elif a == "--max-diagram-edges" and i + 1 < len(args): callflow_max_diagram_edges = int(args[i + 1]); i += 2 - elif a in ("-h", "--help") and subcmd == "callflow-html": - print("Usage: graphify export callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH]") - print(" --report PATH path to GRAPH_REPORT.md") - print(" --sections PATH JSON section definitions") - print(" --output HTML output path (default graphify-out/-callflow.html)") - print(" --lang LANG auto, zh-CN, en, etc. (default auto)") - print(" --max-sections N maximum auto-derived sections (default 15)") - print(" --diagram-scale N Mermaid diagram scale (default 1.0)") - print(" --max-diagram-nodes N representative nodes per section (default 18)") - print(" --max-diagram-edges N representative edges per section (default 24)") - sys.exit(0) elif a == "--node-limit" and i + 1 < len(args): node_limit = int(args[i + 1]); i += 2 elif a == "--no-viz": diff --git a/graphify/prs.py b/graphify/prs.py index 9534e6c006..3482bcb1ee 100644 --- a/graphify/prs.py +++ b/graphify/prs.py @@ -688,6 +688,10 @@ def cmd_prs(argv: list[str]) -> None: pr_number: int | None = None graph_path = Path(_default_graph_json()) + if any(arg in ("-h", "--help") for arg in argv): + print(__doc__) + return + i = 0 while i < len(argv): arg = argv[i] @@ -711,9 +715,6 @@ def cmd_prs(argv: list[str]) -> None: graph_path = Path(argv[i + 1]); i += 1 elif arg.lstrip("#").isdigit(): pr_number = int(arg.lstrip("#")) - elif arg in ("-h", "--help"): - print(__doc__) - return i += 1 if base is None: diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py index 05fd2bbf42..809031b217 100644 --- a/tests/test_cli_help.py +++ b/tests/test_cli_help.py @@ -32,9 +32,23 @@ def _invoke_main(monkeypatch, capsys, tmp_path, args: list[str]): (["export", "callflow-html", "-h"], "Usage: graphify export callflow-html"), (["prs", "--help"], "graphify prs — graph-aware PR dashboard"), (["prs", "-h"], "graphify prs — graph-aware PR dashboard"), + (["tree", "--output", "custom.html", "--help"], "Usage: graphify tree"), + (["reflect", "--out", "custom.md", "--help"], "usage: graphify reflect"), + ( + ["export", "callflow-html", "--output", "custom.html", "--help"], + "Usage: graphify export callflow-html", + ), + (["prs", "--repo", "owner/repo", "--help"], "graphify prs — graph-aware PR dashboard"), + (["tree", "--output", "--help"], "Usage: graphify tree"), + (["reflect", "--out", "--help"], "usage: graphify reflect"), + ( + ["export", "callflow-html", "--output", "--help"], + "Usage: graphify export callflow-html", + ), + (["prs", "--repo", "--help"], "graphify prs — graph-aware PR dashboard"), ], ) -def test_exact_safe_help_reaches_detailed_handler( +def test_help_reaches_detailed_handler_before_argument_parsing( tmp_path, monkeypatch, capsys, args, expected ): captured = _invoke_main(monkeypatch, capsys, tmp_path, args) @@ -48,10 +62,6 @@ def test_exact_safe_help_reaches_detailed_handler( @pytest.mark.parametrize( "args", [ - ["tree", "--output", "--help"], - ["reflect", "--out", "--help"], - ["export", "callflow-html", "--output", "--help"], - ["prs", "--repo", "--help"], ["tree", "-?"], ["benchmark", "--help"], ["export", "html", "--help"], From 2c16d8c410c71e4e72ee44c8029f9cd445768d1a Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Fri, 21 Aug 2026 23:54:33 -0500 Subject: [PATCH 3/4] fix(cli): return cleanly from callflow help --- graphify/cli.py | 2 +- tests/test_cli_help.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/graphify/cli.py b/graphify/cli.py index e8f74e1092..f64280bb76 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2530,7 +2530,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": print(" --diagram-scale N Mermaid diagram scale (default 1.0)") print(" --max-diagram-nodes N representative nodes per section (default 18)") print(" --max-diagram-edges N representative edges per section (default 24)") - sys.exit(0) + return graph_path = Path(_GRAPHIFY_OUT) / "graph.json" graph_path_explicit = False labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json" diff --git a/tests/test_cli_help.py b/tests/test_cli_help.py index 809031b217..588c93f0c8 100644 --- a/tests/test_cli_help.py +++ b/tests/test_cli_help.py @@ -14,10 +14,7 @@ def _invoke_main(monkeypatch, capsys, tmp_path, args: list[str]): monkeypatch.chdir(tmp_path) monkeypatch.setattr(sys, "argv", ["graphify", *args]) with patch("graphify.__main__._check_skill_version"): - try: - main() - except SystemExit as exc: - assert exc.code in (0, None) + main() return capsys.readouterr() From 2865405ae2e53b0b166c73c6ffd31b18912ed620 Mon Sep 17 00:00:00 2001 From: oleksii-tumanov Date: Sat, 22 Aug 2026 00:23:34 -0500 Subject: [PATCH 4/4] docs(changelog): clarify help guard scope --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6930b4e76..7edfe2ba61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1180,7 +1180,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.7.15 (2026-05-11) -- Fix: `-h`/`--help`/`-?` in any position now stops execution — previously `graphify cursor install --help` silently installed into Cursor; `graphify benchmark --help` crashed with FileNotFoundError (#821) +- Fix: help tokens no longer trigger execution for commands routed through the universal help guard; free-text commands remain exempt so tokens such as `--help` can still be treated as input — previously `graphify cursor install --help` silently installed into Cursor; `graphify benchmark --help` crashed with FileNotFoundError (#821) - Fix: `--version`, `-v`, and `graphify version` now print the installed version and exit (#818) - Fix: `GRAPHIFY_OLLAMA_NUM_CTX=` no longer falls back to hardcoded 131072 (which exhausted VRAM) — it now falls through to the auto-derived value and prints a warning (#820) - Fix: when `GRAPHIFY_OLLAMA_NUM_CTX` is set smaller than the estimated chunk size, graphify now warns explicitly that Ollama will silently truncate the prompt and suggests a corrected `--token-budget` (#820)