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
16 changes: 11 additions & 5 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2780,7 +2780,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"):
print("Usage: graphify export <format>", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz] [--output PATH]", file=sys.stderr)
print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr)
print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr)
Expand Down Expand Up @@ -3002,19 +3002,25 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":

if subcmd == "html":
from graphify.export import to_html as _to_html
# --output overrides the default graph.html target (the same shared
# --output flag already parsed above for callflow-html), so callers
# that don't want a generic filename - e.g. a content+date name like
# graphify-out/{slug}-{date}.html - can ask for one explicitly instead
# of post-renaming the file themselves. Omit --output and behavior is
# byte-for-byte the same as before this flag existed.
html_target = callflow_output if callflow_output is not None else (out_dir / "graph.html")
if no_viz:
html_target = out_dir / "graph.html"
if html_target.exists():
html_target.unlink()
print("--no-viz: skipped graph.html")
print(f"--no-viz: skipped {html_target.name}")
else:
# Over-cap fallback (#1019): force the community-aggregation
# path so the oversized graph still renders a usable artifact.
_effective_node_limit = 5000 if _over_cap else node_limit
_to_html(G, communities, str(out_dir / "graph.html"),
_to_html(G, communities, str(html_target),
community_labels=labels or None, node_limit=_effective_node_limit)
if G.number_of_nodes() <= _effective_node_limit:
print(f"graph.html written - open in any browser, no server needed")
print(f"{html_target.name} written - open in any browser, no server needed")
if _over_cap:
sys.exit(0)

Expand Down
72 changes: 57 additions & 15 deletions graphify/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: "Use for any question about a codebase, its architecture, file rela

# /graphify

Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language markdown report. The markdown report and HTML are always named `{corpus-slug}-{YYYY-MM-DD}` - see Step 2.6.

## Usage

Expand Down Expand Up @@ -142,11 +142,47 @@ Then act on it:
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 2.6 if not.

### Step 2.5 - Video and audio (only if video files detected)

Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3 (after computing the output basename in Step 2.6 below).

### Step 2.6 - Compute the output basename

The markdown report is always named `{corpus-slug}-{YYYY-MM-DD}-report.md`, never the
generic `GRAPH_REPORT.md` — never leave a report sitting in `graphify-out/` with a name
that doesn't say what's in it or when it was generated. Compute the basename once and reuse
it for the rest of this run:

```bash
$(cat graphify-out/.graphify_python) -c "
import re
from datetime import date
from pathlib import Path

target = Path('INPUT_PATH').resolve()
slug = re.sub(r'[^a-z0-9]+', '-', target.name.lower()).strip('-') or 'graph'
base = f'{slug}-{date.today().isoformat()}'
Path('graphify-out/.graphify_output_base').write_text(base, encoding=\"utf-8\")
print(f'Output basename: {base}')
"
```

Replace INPUT_PATH with the same path used in Step 2. Later steps read this back with
`Path('graphify-out/.graphify_output_base').read_text(encoding=\"utf-8\").strip()` - do not
recompute it mid-run, the date must stay the same for every file this run produces.

`graphify-out/graph.json` is the one exception - it keeps its fixed name on purpose,
because `--update`, `--cluster-only`, `query`, `path`, `explain`, `--neo4j-push`,
`--falkordb-push`, `--mcp`, and the benchmark step all read it back in by that exact path
across sessions. Renaming it per run would break "ask questions weeks later."

**Known scope gap:** `--cluster-only`/`label` and `--watch`/the git commit hook still write
the fixed `GRAPH_REPORT.md` and `graph.html` names directly from `graphify/cli.py` (not from
this skill file), because those paths carry their own stale-marker and shrink-guard state
keyed to those literal filenames (see `graph.html stale marker` handling in `cli.py`). Giving
those paths dated names too is a larger, separate change - out of scope here.

### Step 3 - Extract entities and relationships

Expand Down Expand Up @@ -436,7 +472,8 @@ if not wrote:
print('If this shrink is intentional (you deleted files), re-run a full build with --force.')
raise SystemExit(1)
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
output_base = Path('graphify-out/.graphify_output_base').read_text(encoding=\"utf-8\").strip()
Path(f'graphify-out/{output_base}-report.md').write_text(report, encoding=\"utf-8\")
analysis = {
'communities': {str(k): v for k, v in communities.items()},
'cohesion': {str(k): v for k, v in cohesion.items()},
Expand Down Expand Up @@ -512,7 +549,8 @@ labels = LABELS_DICT
questions = suggest_questions(G, communities, labels)

report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, 'INPUT_PATH', suggested_questions=questions)
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
output_base = Path('graphify-out/.graphify_output_base').read_text(encoding=\"utf-8\").strip()
Path(f'graphify-out/{output_base}-report.md').write_text(report, encoding=\"utf-8\")
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
# Re-export so graph.json nodes carry the curated community_name (#2490).
# Same extraction as Step 4, so the #479 shrink-guard passes on node count;
Expand Down Expand Up @@ -541,11 +579,13 @@ graphify export obsidian
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
```

Generate the HTML graph (always, unless `--no-viz`):
Generate the HTML graph (always, unless `--no-viz`). Pass `--output` with the basename from
Step 2.6 so the file is named `{corpus-slug}-{date}.html` instead of the generic `graph.html`:

```bash
graphify export html # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz
OUTPUT_BASE=$(cat graphify-out/.graphify_output_base)
graphify export html --output "graphify-out/${OUTPUT_BASE}.html" # auto-aggregates to community view if graph > 5000 nodes
# or: graphify export html --no-viz --output "graphify-out/${OUTPUT_BASE}.html"
```

### Steps 6b-8 - Wiki, Neo4j, FalkorDB, SVG, GraphML, MCP, benchmark (only on their flags)
Expand Down Expand Up @@ -618,28 +658,30 @@ cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
"
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json graphify-out/.graphify_output_base
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
rm -f graphify-out/.needs_update 2>/dev/null || true
```

Replace INPUT_PATH with the actual path (same value used in Steps 4-5) so the manifest is relativized to the scan root.

Tell the user (omit the obsidian line unless --obsidian was given):
Before running the cleanup above, read `graphify-out/.graphify_output_base` (call its contents `OUTPUT_BASE`) so you can compose the message below - it deletes that file.

Tell the user (substitute OUTPUT_BASE with the value you just read; omit the obsidian line unless --obsidian was given):
```
Graph complete. Outputs in PATH_TO_DIR/graphify-out/

graph.html - interactive graph, open in browser
GRAPH_REPORT.md - audit report
graph.json - raw graph data
obsidian/ - Obsidian vault (only if --obsidian was given)
OUTPUT_BASE.html - interactive graph, open in browser
OUTPUT_BASE-report.md - audit report
graph.json - raw graph data (fixed name - persists across --update/query/path/explain runs)
obsidian/ - Obsidian vault (only if --obsidian was given)
```

If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi

Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.

Then paste these sections from GRAPH_REPORT.md directly into the chat:
Then paste these sections from the `OUTPUT_BASE-report.md` file directly into the chat:
- God Nodes
- Surprising Connections
- Suggested Questions
Expand Down
4 changes: 2 additions & 2 deletions graphify/skills/claude/references/add-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ $(cat graphify-out/.graphify_python) -m graphify.watch INPUT_PATH --debounce 3

Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:

- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically. `--watch` runs the installed `graphify.watch` module directly (not the SKILL.md-driven pipeline), so it still writes these fixed names rather than a dated `{corpus-slug}-{date}` basename - see the Step 2.6 scope note in `skill.md`.
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required) - that follow-up `--update` run DOES produce dated output as described in `skill.md`.

Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.

Expand Down
4 changes: 3 additions & 1 deletion graphify/skills/claude/references/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ graphify hook uninstall # remove
graphify hook status # check
```

After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those.
After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those (which does produce a dated `{corpus-slug}-{date}-report.md`, unlike this hook).

Like `--watch`, this hook runs the installed `graphify` binary directly, so it still uses the fixed `GRAPH_REPORT.md`/`graph.html` names rather than a dated basename - see the Step 2.6 scope note in `skill.md`.

If a post-commit hook already exists, graphify appends to it rather than replacing it.

Expand Down
23 changes: 23 additions & 0 deletions graphify/skills/claude/references/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ Load this only when the user passed `--update` or `--cluster-only`. A first-time

Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.

This flow reuses Steps 4-8 from the main SKILL.md as-is (see below), which read the output
basename from `graphify-out/.graphify_output_base` (Step 2.6). `--update` skips Steps 1-3, so
recompute that basename first - same slug as the original run (derived from the same path),
but today's date, since this run produces a fresh report:

```bash
$(cat graphify-out/.graphify_python) -c "
import re
from datetime import date
from pathlib import Path

target = Path('INPUT_PATH').resolve()
slug = re.sub(r'[^a-z0-9]+', '-', target.name.lower()).strip('-') or 'graph'
base = f'{slug}-{date.today().isoformat()}'
Path('graphify-out/.graphify_output_base').write_text(base, encoding=\"utf-8\")
print(f'Output basename: {base}')
"
```

Replace INPUT_PATH with the same path used in the original run.

```bash
$(cat graphify-out/.graphify_python) -c "
import sys, json
Expand Down Expand Up @@ -208,3 +229,5 @@ graphify cluster-only .
```

`graphify cluster-only .` is **self-contained**: it re-clusters, names communities, and regenerates `GRAPH_REPORT.md`, `graph.json`, and `graph.html` from the existing graph. **Do not re-run Steps 5–9** — they read intermediate files (`.graphify_extract.json`, `.graphify_detect.json`, `.graphify_analysis.json`) that a prior build's cleanup (Step 9) already deleted, so they raise `FileNotFoundError` (#1392). When it finishes, present the refreshed `GRAPH_REPORT.md` summary as usual.

Note: unlike the main build path (Step 2.6), `graphify cluster-only .` always writes the fixed `GRAPH_REPORT.md`/`graph.html` names, not a dated `{corpus-slug}-{date}` basename - that command is implemented entirely inside `graphify/cli.py` with its own stale-marker/shrink-guard state keyed to those literal filenames, which this change intentionally left untouched.
22 changes: 22 additions & 0 deletions tests/test_cli_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@ def test_export_html_no_viz_removes_file(tmp_path):
assert not (out / "graph.html").exists()


def test_export_html_output_flag_writes_to_custom_path(tmp_path):
"""--output lets callers avoid the generic graph.html name (e.g. a
content+date filename) without post-renaming the file themselves. Default
behavior (no --output) must stay byte-for-byte the same as before."""
out = _make_graph(tmp_path)
custom = out / "my-project-2026-01-01.html"
r = _run(["export", "html", "--output", str(custom)], tmp_path)
assert r.returncode == 0, r.stderr
assert custom.exists()
assert custom.stat().st_size > 0
assert not (out / "graph.html").exists()


def test_export_html_no_viz_removes_file_at_output_path(tmp_path):
out = _make_graph(tmp_path)
custom = out / "my-project-2026-01-01.html"
custom.write_text("<html/>")
r = _run(["export", "html", "--no-viz", "--output", str(custom)], tmp_path)
assert r.returncode == 0, r.stderr
assert not custom.exists()


def test_export_html_error_without_graph(tmp_path):
r = _run(["export", "html"], tmp_path)
assert r.returncode != 0
Expand Down