From 2faeed99a2e9939772e304ceec61d918be8d022f Mon Sep 17 00:00:00 2001 From: Safi Date: Thu, 23 Apr 2026 19:59:14 +0100 Subject: [PATCH] feat: cross-repo merge-graphs; fix #527 CLAUDE_CONFIG_DIR; fix #524 graphify-out excluded from source scan --- graphify/__main__.py | 50 +++++++++++++++++++++++++++++++++++++++++++- graphify/detect.py | 1 + graphify/skill.md | 20 +++++++++++++++--- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index c94f11b2..be14274f 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -148,7 +148,12 @@ def install(platform: str = "claude") -> None: print(f"error: {cfg['skill_file']} not found in package - reinstall graphify", file=sys.stderr) sys.exit(1) - skill_dst = Path.home() / cfg["skill_dst"] + import os as _os + if platform in ("claude", "windows") and _os.environ.get("CLAUDE_CONFIG_DIR"): + _claude_base = Path(_os.environ["CLAUDE_CONFIG_DIR"]) + skill_dst = _claude_base / "skills" / "graphify" / "SKILL.md" + else: + skill_dst = Path.home() / cfg["skill_dst"] skill_dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy(skill_src, skill_dst) (skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8") @@ -977,6 +982,8 @@ def main() -> None: print(" explain \"X\" plain-language explanation of a node and its neighbors") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" clone clone a GitHub repo locally and print its path for /graphify") + print(" merge-graphs merge two or more graph.json files into one cross-repo graph") + print(" --out output path (default: graphify-out/merged-graph.json)") print(" --branch checkout a specific branch (default: repo default)") print(" --out clone to a custom directory (default: ~/.graphify/repos//)") print(" add fetch a URL and save it to ./raw, then update the graph") @@ -1415,6 +1422,47 @@ def main() -> None: from graphify.watch import check_update check_update(Path(sys.argv[2]).resolve()) sys.exit(0) + elif cmd == "merge-graphs": + # graphify merge-graphs graph1.json graph2.json ... --out merged.json + args = sys.argv[2:] + graph_paths: list[Path] = [] + out_path = Path("graphify-out/merged-graph.json") + i = 0 + while i < len(args): + if args[i] == "--out" and i + 1 < len(args): + out_path = Path(args[i + 1]); i += 2 + else: + graph_paths.append(Path(args[i])); i += 1 + if len(graph_paths) < 2: + print("Usage: graphify merge-graphs [...] [--out merged.json]", file=sys.stderr) + sys.exit(1) + import networkx as _nx + from networkx.readwrite import json_graph as _jg + graphs = [] + for gp in graph_paths: + if not gp.exists(): + print(f"error: not found: {gp}", file=sys.stderr) + sys.exit(1) + data = json.loads(gp.read_text(encoding="utf-8")) + try: + G = _jg.node_link_graph(data, edges="links") + except TypeError: + G = _jg.node_link_graph(data) + # Tag every node with which repo it came from + repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name + for node in G.nodes: + G.nodes[node].setdefault("repo", repo_tag) + graphs.append(G) + merged = _nx.compose_all(graphs) + try: + out_data = _jg.node_link_data(merged, edges="links") + except TypeError: + out_data = _jg.node_link_data(merged) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(out_data, indent=2), encoding="utf-8") + print(f"Merged {len(graphs)} graphs → {merged.number_of_nodes()} nodes, {merged.number_of_edges()} edges") + print(f"Written to: {out_path}") + elif cmd == "clone": if len(sys.argv) < 3: print("Usage: graphify clone [--branch ] [--out ]", file=sys.stderr) diff --git a/graphify/detect.py b/graphify/detect.py index 39262587..33844929 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -241,6 +241,7 @@ _SKIP_DIRS = { "site-packages", "lib64", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".tox", ".eggs", "*.egg-info", + "graphify-out", # never treat own output as source input (#524) } # Large generated files that are never useful to extract diff --git a/graphify/skill.md b/graphify/skill.md index abbf9019..6d45b153 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -15,6 +15,7 @@ Turn any folder of files into a navigable knowledge graph with community detecti /graphify # full pipeline on specific path /graphify https://github.com// # clone repo then run full pipeline on it /graphify https://github.com// --branch # clone a specific branch +/graphify ... # clone multiple repos, build each, merge into one cross-repo graph /graphify --mode deep # thorough extraction, richer INFERRED edges /graphify --update # incremental - re-extract only new/changed files /graphify --directed # build directed graph (preserves edge direction: source→target) @@ -63,15 +64,28 @@ If the path argument starts with `https://github.com/` or `http://github.com/`, Follow these steps in order. Do not skip steps. -### Step 0 - Clone GitHub repo (only if a GitHub URL was given) +### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given) +**Single repo:** ```bash -# Clone the repo (or pull if already cloned) and capture the local path LOCAL_PATH=$(graphify clone [--branch ]) # Use LOCAL_PATH as the target for all subsequent steps ``` -Graphify clones into `~/.graphify/repos//` so repeated calls on the same URL reuse the existing clone. Print the resolved path to the user before continuing. If `--branch` was specified, pass it through. +**Multiple repos (cross-repo graph):** +```bash +# Clone each repo, run the full pipeline on each, then merge +graphify clone # → ~/.graphify/repos// +graphify clone # → ~/.graphify/repos// +# Run /graphify on each local path to produce their graph.json files +# Then merge: +graphify merge-graphs \ + ~/.graphify/repos///graphify-out/graph.json \ + ~/.graphify/repos///graphify-out/graph.json \ + --out graphify-out/cross-repo-graph.json +``` + +Graphify clones into `~/.graphify/repos//` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin. ### Step 1 - Ensure graphify is installed