Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<invalid>` 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)
Expand Down
20 changes: 19 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,19 @@ def main() -> None:
raise


def _has_detailed_help_handler(cmd: str, args: list[str]) -> bool:
"""Return whether this invocation has a side-effect-free help handler."""
help_flags = {"-h", "--help"}
if cmd in {"prs", "reflect", "tree"}:
return any(arg in help_flags for arg in args)
return (
cmd == "export"
and len(args) >= 2
and args[0] == "callflow-html"
and any(arg in help_flags for arg in args[1:])
)


def _run_cli() -> None:
for _stream in (sys.stdout, sys.stderr):
if _stream is not None and hasattr(_stream, "reconfigure"):
Expand Down Expand Up @@ -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

Expand Down
43 changes: 23 additions & 20 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand All @@ -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():
Expand Down Expand Up @@ -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/<project>-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)")
return
graph_path = Path(_GRAPHIFY_OUT) / "graph.json"
graph_path_explicit = False
labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json"
Expand Down Expand Up @@ -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/<project>-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":
Expand Down
7 changes: 4 additions & 3 deletions graphify/prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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:
Expand Down
74 changes: 74 additions & 0 deletions tests/test_cli_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""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"):
main()
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"),
(["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_help_reaches_detailed_handler_before_argument_parsing(
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", "-?"],
["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()) == []
Loading