Repository navigation
Bug: Inconsistent help-flag handling across graphify subcommands #821
Description
Summary
graphify's subcommands handle -h / --help / unknown flags inconsistently. Out of 57 subcommands I probed:
- 22 (command, flag) pairs out of 228 print help. Only 7 subcommands honor
-h/--helpat all. - 170 pairs silently ignore the flag and execute the underlying command. This includes destructive operations: every
<platform> installand every<platform> uninstallwill run regardless of what flags follow. - 4 pairs crash with a Python Traceback. All on
graphify benchmark—--helpis parsed as a positionalgraph.jsonpath. -?is unsupported everywhere (only matters if you want to be friendly to Windows-CLI muscle memory).
The practical consequence is that an agent or a user trying to learn the tool (graphify cursor install --help is the natural way to ask "what does this do?") will silently install graphify into Cursor instead of getting documentation. Running my probe script accidentally installed graphify into 15 platforms because every <platform> install --help ran the install.
Reproduction
# Crash:
$ graphify benchmark --help
Traceback (most recent call last):
File "/home/.../bin/graphify", line 6, in <module>
sys.exit(main())
File ".../graphify/__main__.py", line 2169, in main
result = run_benchmark(graph_path, corpus_words=corpus_words)
File ".../graphify/benchmark.py", line 100, in run_benchmark
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
...
FileNotFoundError: [Errno 2] No such file or directory: '--help'
# Silent execution (no help shown — command actually runs):
$ graphify cursor install --help
graphify rule written to ./.cursor/rules/graphify.mdc
...
$ graphify cursor install --xyz-not-a-real-flag
graphify rule already exists at .cursor/rules/graphify.mdc (no change)
$ graphify uninstall --help
Uninstalling graphify from all detected platforms...
...Full probe script + 228 raw transcripts are attached at the bottom of this issue.
Per-subcommand matrix
Legend: OK = help shown · TRACEBACK = crashed · RAN = exit 0, command actually executed · no = non-zero exit, no help.
| command | -h |
--help |
-? |
unknown flag |
|---|---|---|---|---|
install |
OK | OK | no | no |
uninstall |
RAN | RAN | RAN | RAN |
path |
OK | OK | OK | OK |
explain |
RAN | RAN | RAN | RAN |
clone |
no | no | no | no |
merge-driver |
OK | OK | OK | OK |
merge-graphs |
OK | OK | OK | OK |
add |
no | no | no | no |
watch |
no | no | no | no |
update |
no | no | no | no |
cluster-only |
no | RAN | no | RAN |
query |
RAN | RAN | RAN | RAN |
save-result |
OK | OK | OK | OK |
check-update |
RAN | RAN | RAN | RAN |
tree |
OK | OK | RAN | RAN |
extract |
no | no | no | no |
global add |
no | no | no | no |
global remove |
no | no | no | no |
global list |
RAN | RAN | RAN | RAN |
global path |
RAN | RAN | RAN | RAN |
benchmark |
TRACEBACK | TRACEBACK | TRACEBACK | TRACEBACK |
export callflow-html |
OK | OK | RAN | RAN |
hook install |
RAN | RAN | RAN | RAN |
hook uninstall |
RAN | RAN | RAN | RAN |
hook status |
RAN | RAN | RAN | RAN |
<platform> install (gemini, cursor, claude, codex, opencode, aider, copilot, vscode, claw, droid, trae, trae-cn, antigravity, hermes, kiro, pi) |
RAN | RAN | RAN | RAN |
<platform> uninstall (same 16 platforms) |
RAN | RAN | RAN | RAN |
Per-flag tallies (57 commands)
| flag | OK | TRACEBACK | RAN | no |
|---|---|---|---|---|
-h |
7 | 1 | 41 | 8 |
--help |
7 | 1 | 42 | 7 |
-? |
4 | 1 | 43 | 9 |
| unknown flag | 4 | 1 | 44 | 8 |
Why this matters
- Safety.
graphify gemini install --helpshouldn't perform an install. It currently does. Same for the other 31 install/uninstall variants. A user trying to learn the tool can mutate their system instead. - Agent friendliness. LLM coding agents (Claude Code, Cursor, Copilot, etc.) routinely probe
--helpto discover argument usage. The current behavior either crashes them (benchmark) or silently triggers side effects. - Discoverability. The top-level
graphify --helpis good, but there's no way to drill into a single subcommand's documentation. You either re-read the entire 100-line top-level help or guess. - Consistency. 7-out-of-57 is hard to predict — every subcommand needs the same contract.
Proposed fix
Adopt standard CLI conventions (POSIX/GNU; what argparse and click give you by default):
-h/--helpalways short-circuit. Before any positional-arg binding, before any side effect, print the subcommand's usage to stdout and exit 0. This is the most important fix — it eliminates the silent-install footgun and fixesbenchmark --help.- Unknown flags error out. Print
error: unrecognized argument: --fooplus the usage line to stderr, exit 2. (Matches whatgraphify helpalready does at the top level:error: unknown command 'help'.) - Every subcommand has its own
--help. It should describe: what the command does, positional arguments, flags with defaults, and 1–2 examples. - Optional: support
-?as an alias for-hif you care about Windows-CLI muscle memory. Not required by POSIX, but cheap. - Optional but recommended: refactor to
argparsesubparsers orclick. The fact that nearly every subcommand exhibits this bug suggests a single hand-rolled argv loop. A subparser-per-command structure fixes the entire class of bug in one pass, and you get tab completion viaargcompletefor free.
Concrete acceptance criteria
For every subcommand listed in graphify --help:
-
graphify <cmd> -hprints usage and exits 0; does NOT execute the command. -
graphify <cmd> --helpsame. -
graphify <cmd> --bogus-flagexits non-zero with a usage hint; does NOT execute the command. -
graphify benchmark --helpdoes not produce a Traceback.
A regression test could simply re-run the attached probe script and assert: 0 TRACEBACK, 0 RAN, 100% OK across -h, --help, and the unknown-flag column.
Appendix A: Test Script
#!/usr/bin/env python3
"""
Probe every `graphify` subcommand with `-h`, `--help`, `-?`, and a deliberately
bogus flag, then summarize which commands emit a usage/help message vs.
crash with a Traceback vs. silently do nothing.
Output:
/tmp/graphify-cmd-help/raw/<cmd>__<flag>.txt per-invocation raw output
/tmp/graphify-cmd-help/results.md markdown summary table
/tmp/graphify-cmd-help/results.csv same data, csv form
Run:
python3 /tmp/graphify-cmd-help/test_help.py
"""
import csv
import datetime
import re
import subprocess
import sys
from pathlib import Path
OUTDIR = Path("/tmp/graphify-cmd-help")
RAWDIR = OUTDIR / "raw"
RAWDIR.mkdir(parents=True, exist_ok=True)
# Subcommands extracted from `graphify --help` output (2026-05-11).
# Multi-word commands (e.g. "global add", "hook install") are listed in full.
COMMANDS = [
"install",
"uninstall",
"path",
"explain",
"clone",
"merge-driver",
"merge-graphs",
"add",
"watch",
"update",
"cluster-only",
"query",
"save-result",
"check-update",
"tree",
"extract",
"global add",
"global remove",
"global list",
"global path",
"benchmark",
"export callflow-html",
"hook install",
"hook uninstall",
"hook status",
"gemini install",
"gemini uninstall",
"cursor install",
"cursor uninstall",
"claude install",
"claude uninstall",
"codex install",
"codex uninstall",
"opencode install",
"opencode uninstall",
"aider install",
"aider uninstall",
"copilot install",
"copilot uninstall",
"vscode install",
"vscode uninstall",
"claw install",
"claw uninstall",
"droid install",
"droid uninstall",
"trae install",
"trae uninstall",
"trae-cn install",
"trae-cn uninstall",
"antigravity install",
"antigravity uninstall",
"hermes install",
"hermes uninstall",
"kiro install",
"kiro uninstall",
"pi install",
"pi uninstall",
]
# Flags to probe. The last is a deliberately invalid flag — a well-behaved CLI
# should reject it with a usage message.
FLAGS = ["-h", "--help", "-?", "--xyz-bogus-test-flag-987"]
# Classification labels.
HELP = "HELP" # Usage/help message shown
TRACEBACK = "TRACEBACK" # Python Traceback in output
RAN = "RAN_OK" # Exit 0 with no help (probably executed the command!)
NO_HELP = "NO_HELP" # Non-zero exit and no help message
TIMEOUT = "TIMEOUT" # Command hung past the deadline
# Glyphs for the markdown table.
GLYPH = {
HELP: "OK",
TRACEBACK: "TRACEBACK",
RAN: "RAN",
NO_HELP: "no",
TIMEOUT: "timeout",
}
def safe_name(cmd: str, flag: str) -> str:
s = f"{cmd}__{flag}"
return re.sub(r"[^A-Za-z0-9._-]+", "_", s).strip("_")
def classify(returncode: int | None, output: str) -> str:
if returncode is None:
return TIMEOUT
if "Traceback (most recent call last)" in output:
return TRACEBACK
head = "\n".join(output.splitlines()[:25])
if re.search(r"(?i)^\s*usage:", head, flags=re.MULTILINE):
return HELP
if re.search(r"(?i)usage:\s*graphify", output):
return HELP
if returncode == 0:
return RAN
return NO_HELP
def run_one(cmd: str, flag: str) -> tuple[str, str]:
argv = ["graphify", *cmd.split(), flag]
try:
proc = subprocess.run(
argv,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=15,
)
output = proc.stdout
rc = proc.returncode
except subprocess.TimeoutExpired as e:
output = (e.stdout or "") + "\n[timed out after 15s]"
rc = None
raw_path = RAWDIR / f"{safe_name(cmd, flag)}.txt"
raw_path.write_text(
f"$ {' '.join(argv)}\n"
f"--- exit: {rc if rc is not None else 'TIMEOUT'} ---\n"
f"{output}"
)
return classify(rc, output), output
def main() -> int:
results: list[dict[str, str]] = []
for cmd in COMMANDS:
row = {"command": cmd}
for flag in FLAGS:
label, _ = run_one(cmd, flag)
row[flag] = label
print(f"{cmd:35s} {flag:35s} -> {label}", flush=True)
results.append(row)
# CSV
csv_path = OUTDIR / "results.csv"
with csv_path.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["command", *FLAGS])
w.writeheader()
for r in results:
w.writerow(r)
# Markdown
md_lines: list[str] = []
md_lines.append("# graphify help-flag consistency probe")
md_lines.append("")
md_lines.append(
f"Generated by `{Path(__file__).name}` on "
f"{datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')}."
)
md_lines.append("")
md_lines.append("Legend:")
md_lines.append("")
md_lines.append("- **OK** — command printed a usage/help message")
md_lines.append("- **TRACEBACK** — command crashed with a Python Traceback")
md_lines.append("- **RAN** — exit 0 with no help shown (probably executed the underlying command!)")
md_lines.append("- **no** — non-zero exit, no help message")
md_lines.append("- **timeout** — command did not return within 15s")
md_lines.append("")
md_lines.append("The `--xyz-bogus-test-flag-987` column probes how the command handles an unknown flag (i.e. incorrect usage).")
md_lines.append("")
header = "| command | `-h` | `--help` | `-?` | bogus flag |"
sep = "|---|---|---|---|---|"
md_lines.append(header)
md_lines.append(sep)
for r in results:
cells = [f"`{r['command']}`"] + [GLYPH[r[f]] for f in FLAGS]
md_lines.append("| " + " | ".join(cells) + " |")
md_lines.append("")
# Summary
totals: dict[str, int] = {}
for r in results:
for f in FLAGS:
totals[r[f]] = totals.get(r[f], 0) + 1
md_lines.append("## Totals across all (command, flag) probes")
md_lines.append("")
md_lines.append("| outcome | count |")
md_lines.append("|---|---|")
for label in (HELP, TRACEBACK, RAN, NO_HELP, TIMEOUT):
md_lines.append(f"| {GLYPH[label]} | {totals.get(label, 0)} |")
md_lines.append("")
# Per-flag tallies
md_lines.append("## Per-flag tallies")
md_lines.append("")
md_lines.append("| flag | HELP | TRACEBACK | RAN | NO_HELP | TIMEOUT |")
md_lines.append("|---|---|---|---|---|---|")
for f in FLAGS:
c = {HELP: 0, TRACEBACK: 0, RAN: 0, NO_HELP: 0, TIMEOUT: 0}
for r in results:
c[r[f]] += 1
md_lines.append(
f"| `{f}` | {c[HELP]} | {c[TRACEBACK]} | {c[RAN]} | {c[NO_HELP]} | {c[TIMEOUT]} |"
)
md_lines.append("")
# Notable failures
md_lines.append("## Notable failures")
md_lines.append("")
md_lines.append("Commands that emitted a Traceback for at least one help flag:")
md_lines.append("")
for r in results:
bad = [f for f in FLAGS if r[f] == TRACEBACK]
if bad:
md_lines.append(f"- `{r['command']}` → {', '.join(bad)}")
md_lines.append("")
# Sample Traceback for the benchmark command (the bug the user reported).
bench_raw = RAWDIR / "benchmark__--help.txt"
if bench_raw.exists():
md_lines.append("### Sample Traceback: `graphify benchmark --help`")
md_lines.append("")
md_lines.append("```")
md_lines.append(bench_raw.read_text().rstrip())
md_lines.append("```")
md_lines.append("")
md_lines.append("Commands where every help-flag variant failed to show usage:")
md_lines.append("")
for r in results:
helpish = [f for f in FLAGS[:3] if r[f] == HELP]
if not helpish:
md_lines.append(f"- `{r['command']}`")
md_lines.append("")
(OUTDIR / "results.md").write_text("\n".join(md_lines))
print(f"\nWrote {OUTDIR / 'results.md'}")
print(f"Wrote {csv_path}")
print(f"Raw outputs in {RAWDIR}/")
return 0
if __name__ == "__main__":
sys.exit(main())
Activity
fix Graphify-Labs#821 Graphify-Labs#818 Graphify-Labs#820: universal …
__main__: lap-29 cherry-pick Graphify-Labs#821 + Graphify-Labs#818 — …
fix Graphify-Labs#821 Graphify-Labs#818 Graphify-Labs#820: universal …
fix Graphify-Labs#821 Graphify-Labs#818 Graphify-Labs#820: universal …