Skip to content

Bug: Inconsistent help-flag handling across graphify subcommands #821

Closed

Description

@sscargal

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/--help at all.
  • 170 pairs silently ignore the flag and execute the underlying command. This includes destructive operations: every <platform> install and every <platform> uninstall will run regardless of what flags follow.
  • 4 pairs crash with a Python Traceback. All on graphify benchmark — --help is parsed as a positional graph.json path.
  • -? 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

  1. Safety. graphify gemini install --help shouldn'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.
  2. Agent friendliness. LLM coding agents (Claude Code, Cursor, Copilot, etc.) routinely probe --help to discover argument usage. The current behavior either crashes them (benchmark) or silently triggers side effects.
  3. Discoverability. The top-level graphify --help is 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.
  4. 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):

  1. -h / --help always 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 fixes benchmark --help.
  2. Unknown flags error out. Print error: unrecognized argument: --foo plus the usage line to stderr, exit 2. (Matches what graphify help already does at the top level: error: unknown command 'help'.)
  3. Every subcommand has its own --help. It should describe: what the command does, positional arguments, flags with defaults, and 1–2 examples.
  4. Optional: support -? as an alias for -h if you care about Windows-CLI muscle memory. Not required by POSIX, but cheap.
  5. Optional but recommended: refactor to argparse subparsers or click. 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 via argcomplete for free.

Concrete acceptance criteria

For every subcommand listed in graphify --help:

  • graphify <cmd> -h prints usage and exits 0; does NOT execute the command.
  • graphify <cmd> --help same.
  • graphify <cmd> --bogus-flag exits non-zero with a usage hint; does NOT execute the command.
  • graphify benchmark --help does 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

  1. added a commit that references this issue on May 11, 2026
    094d8ba
  2. added a commit that references this issue on Aug 13, 2026
    6ba7daf
  3. added a commit that references this issue on Sep 10, 2026
    4ef5c43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions