diff --git a/CHANGELOG.md b/CHANGELOG.md index fae7b427..af2dc0a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,14 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) -## 0.4.9 (2026-04-13) +## 0.4.11 (2026-04-13) + +- Fix: `graphify query` no longer crashes with `ValueError` on MultiGraph graphs — `G.edges[u, v]` replaced with `G[u][v]` + MultiGraph guard (#305) +- Fix: `graphify query` no longer crashes with `AttributeError: 'NoneType' has no attribute 'lower'` when a node has a null `source_file` (#307) +- Fix: MCP server launched from a different directory now correctly derives the `graphify-out` base from the absolute path provided, instead of CWD (#309) +- Fix: `.graphifyignore` patterns from a parent directory now fire correctly when graphify is run on a subfolder — patterns are matched against paths relative to both the scan root and the `.graphifyignore`'s anchor directory (#303) + +## 0.4.10 (2026-04-13) - Fix: `graphify install --platform cursor` no longer crashes — passes `Path(".")` to `_cursor_install` (#281) - Fix: `_agents_uninstall` now only removes the OpenCode plugin when uninstalling the `opencode` platform — other platforms were incorrectly having their OpenCode plugin stripped (#276) @@ -20,6 +27,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Add: `prune_dangling_edges()` utility in `export.py` — removes edges whose source/target is not in the node set (#294) - Fix: Antigravity install injects YAML frontmatter into skill file for native tool discovery; rules now include MCP navigation hint; prints MCP config snippet (#268) - Fix: Windows hook tests now use platform-aware assertions instead of POSIX executable bit checks (#279) +- Add: CLI commands `path`, `explain`, `add`, `watch`, `update`, `cluster-only` now work as bare terminal commands (not just AI skill invocations) — documented in `--help` output (#277) ## 0.4.8 (2026-04-12) diff --git a/README.md b/README.md index 19d6131d..44419a58 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ dist/ *.generated.py ``` -Same syntax as `.gitignore`. Patterns match against file paths relative to the folder you run graphify on. +Same syntax as `.gitignore`. You can keep a single `.graphifyignore` at your repo root — patterns work correctly even when graphify is run on a subfolder. ## How it works @@ -256,11 +256,22 @@ graphify hermes uninstall graphify antigravity install # .agent/rules + .agent/workflows (Google Antigravity) graphify antigravity uninstall -# query the graph directly from the terminal (no AI assistant needed) +# query and navigate the graph directly from the terminal (no AI assistant needed) graphify query "what connects attention to the optimizer?" graphify query "show the auth flow" --dfs graphify query "what is CfgNode?" --budget 500 graphify query "..." --graph path/to/graph.json +graphify path "DigestAuth" "Response" # shortest path between two nodes +graphify explain "SwinTransformer" # plain-language explanation of a node + +# add content and update the graph from the terminal +graphify add https://arxiv.org/abs/1706.03762 # fetch paper, save to ./raw, update graph +graphify add https://... --author "Name" --contributor "Name" + +# incremental update and maintenance +graphify watch ./src # auto-rebuild on code changes +graphify update ./src # re-extract code files, no LLM needed +graphify cluster-only ./my-project # rerun clustering on existing graph.json ``` Works with any mix of file types: diff --git a/graphify/__main__.py b/graphify/__main__.py index ae185c22..8c6b9ec2 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -750,7 +750,18 @@ def main() -> None: print("Usage: graphify ") print() print("Commands:") - print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity)") + print(" install [--platform P] copy skill to platform config dir (claude|windows|codex|opencode|aider|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes)") + print(" path \"A\" \"B\" shortest path between two nodes in graph.json") + print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" explain \"X\" plain-language explanation of a node and its neighbors") + print(" --graph path to graph.json (default graphify-out/graph.json)") + print(" add fetch a URL and save it to ./raw, then update the graph") + print(" --author \"Name\" tag the author of the content") + print(" --contributor \"Name\" tag who added it to the corpus") + print(" --dir target directory (default: ./raw)") + print(" watch watch a folder and rebuild the graph on code changes") + print(" update re-extract code files and update the graph (no LLM needed)") + print(" cluster-only rerun clustering on an existing graph.json and regenerate report") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") print(" --budget N cap output at N tokens (default 2000)") @@ -789,6 +800,8 @@ def main() -> None: print(" trae-cn uninstall remove graphify section from AGENTS.md") print(" antigravity install write .agent/rules + .agent/workflows + skill (Google Antigravity)") print(" antigravity uninstall remove .agent/rules, .agent/workflows, and skill") + print(" hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)") + print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/") print() return @@ -967,6 +980,184 @@ def main() -> None: source_nodes=opts.nodes or None, ) print(f"Saved to {out}") + elif cmd == "path": + if len(sys.argv) < 4: + print("Usage: graphify path \"\" \"\" [--graph path]", file=sys.stderr) + sys.exit(1) + from graphify.serve import _score_nodes + from networkx.readwrite import json_graph + import networkx as _nx + source_label = sys.argv[2] + target_label = sys.argv[3] + graph_path = "graphify-out/graph.json" + args = sys.argv[4:] + for i, a in enumerate(args): + if a == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + _raw = json.loads(gp.read_text(encoding="utf-8")) + try: + G = json_graph.node_link_graph(_raw, edges="links") + except TypeError: + G = json_graph.node_link_graph(_raw) + src_scored = _score_nodes(G, [t.lower() for t in source_label.split()]) + tgt_scored = _score_nodes(G, [t.lower() for t in target_label.split()]) + if not src_scored: + print(f"No node matching '{source_label}' found.", file=sys.stderr) + sys.exit(1) + if not tgt_scored: + print(f"No node matching '{target_label}' found.", file=sys.stderr) + sys.exit(1) + src_nid, tgt_nid = src_scored[0][1], tgt_scored[0][1] + try: + path_nodes = _nx.shortest_path(G, src_nid, tgt_nid) + except (_nx.NetworkXNoPath, _nx.NodeNotFound): + print(f"No path found between '{source_label}' and '{target_label}'.") + sys.exit(0) + hops = len(path_nodes) - 1 + segments = [] + for i in range(len(path_nodes) - 1): + u, v = path_nodes[i], path_nodes[i + 1] + edata = G.edges[u, v] + rel = edata.get("relation", "") + conf = edata.get("confidence", "") + conf_str = f" [{conf}]" if conf else "" + if i == 0: + segments.append(G.nodes[u].get("label", u)) + segments.append(f"--{rel}{conf_str}--> {G.nodes[v].get('label', v)}") + print(f"Shortest path ({hops} hops):\n " + " ".join(segments)) + + elif cmd == "explain": + if len(sys.argv) < 3: + print("Usage: graphify explain \"\" [--graph path]", file=sys.stderr) + sys.exit(1) + from graphify.serve import _find_node + from networkx.readwrite import json_graph + label = sys.argv[2] + graph_path = "graphify-out/graph.json" + args = sys.argv[3:] + for i, a in enumerate(args): + if a == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + _raw = json.loads(gp.read_text(encoding="utf-8")) + try: + G = json_graph.node_link_graph(_raw, edges="links") + except TypeError: + G = json_graph.node_link_graph(_raw) + matches = _find_node(G, label) + if not matches: + print(f"No node matching '{label}' found.") + sys.exit(0) + nid = matches[0] + d = G.nodes[nid] + print(f"Node: {d.get('label', nid)}") + print(f" ID: {nid}") + print(f" Source: {d.get('source_file', '')} {d.get('source_location', '')}".rstrip()) + print(f" Type: {d.get('file_type', '')}") + print(f" Community: {d.get('community', '')}") + print(f" Degree: {G.degree(nid)}") + neighbors = list(G.neighbors(nid)) + if neighbors: + print(f"\nConnections ({len(neighbors)}):") + for nb in sorted(neighbors, key=lambda n: G.degree(n), reverse=True)[:20]: + edata = G.edges[nid, nb] + rel = edata.get("relation", "") + conf = edata.get("confidence", "") + print(f" --> {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]") + if len(neighbors) > 20: + print(f" ... and {len(neighbors) - 20} more") + + elif cmd == "add": + if len(sys.argv) < 3: + print("Usage: graphify add [--author Name] [--contributor Name] [--dir ./raw]", file=sys.stderr) + sys.exit(1) + from graphify.ingest import ingest as _ingest + url = sys.argv[2] + author: str | None = None + contributor: str | None = None + target_dir = Path("raw") + args = sys.argv[3:] + i = 0 + while i < len(args): + if args[i] == "--author" and i + 1 < len(args): + author = args[i + 1]; i += 2 + elif args[i] == "--contributor" and i + 1 < len(args): + contributor = args[i + 1]; i += 2 + elif args[i] == "--dir" and i + 1 < len(args): + target_dir = Path(args[i + 1]); i += 2 + else: + i += 1 + try: + saved = _ingest(url, target_dir, author=author, contributor=contributor) + print(f"Saved to {saved}") + print("Run /graphify --update in your AI assistant to update the graph.") + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + elif cmd == "watch": + watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".") + if not watch_path.exists(): + print(f"error: path not found: {watch_path}", file=sys.stderr) + sys.exit(1) + from graphify.watch import watch as _watch + try: + _watch(watch_path) + except ImportError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + elif cmd == "cluster-only": + watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".") + graph_json = watch_path / "graphify-out" / "graph.json" + if not graph_json.exists(): + print(f"error: no graph found at {graph_json} — run /graphify first", file=sys.stderr) + sys.exit(1) + from networkx.readwrite import json_graph as _jg + from graphify.build import build_from_json + from graphify.cluster import cluster, score_all + from graphify.analyze import god_nodes, surprising_connections, suggest_questions + from graphify.report import generate + from graphify.export import to_json + print("Loading existing graph...") + _raw = json.loads(graph_json.read_text(encoding="utf-8")) + G = build_from_json(_raw) + print(f"Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges") + print("Re-clustering...") + communities = cluster(G) + cohesion = score_all(G, communities) + gods = god_nodes(G) + surprises = surprising_connections(G, communities) + labels = {cid: f"Community {cid}" for cid in communities} + questions = suggest_questions(G, communities, labels) + tokens = {"input": 0, "output": 0} + report = generate(G, communities, cohesion, labels, gods, surprises, + {}, tokens, str(watch_path), suggested_questions=questions) + out = watch_path / "graphify-out" + (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") + to_json(G, communities, str(out / "graph.json")) + print(f"Done — {len(communities)} communities. GRAPH_REPORT.md and graph.json updated.") + + elif cmd == "update": + watch_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(".") + if not watch_path.exists(): + print(f"error: path not found: {watch_path}", file=sys.stderr) + sys.exit(1) + from graphify.watch import _rebuild_code + print(f"Re-extracting code files in {watch_path} (no LLM needed)...") + ok = _rebuild_code(watch_path) + if ok: + print("Code graph updated. For doc/paper/image changes run /graphify --update in your AI assistant.") + else: + print("Nothing to update or rebuild failed — check output above.") + elif cmd == "benchmark": from graphify.benchmark import run_benchmark, print_benchmark graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json" diff --git a/graphify/detect.py b/graphify/detect.py index 0555ce42..fb65923b 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -263,20 +263,18 @@ def _is_noise_dir(part: str) -> bool: return False -def _load_graphifyignore(root: Path) -> list[str]: - """Read .graphifyignore from root **and ancestor directories**, returning patterns. +def _load_graphifyignore(root: Path) -> list[tuple[Path, str]]: + """Read .graphifyignore from root **and ancestor directories**. - Walks upward from *root* towards the filesystem root, collecting patterns - from every ``.graphifyignore`` encountered (like ``.gitignore`` discovery). - The search stops at the filesystem root or at a ``.git`` directory boundary - so it doesn't leak outside the repository. + Returns a list of (anchor_dir, pattern) pairs. Each pattern is matched + against paths relative to both the scan root and the anchor_dir where + the .graphifyignore file was found — so patterns written relative to a + parent directory still work when graphify is run on a subfolder. - Lines starting with # are comments. Blank lines are ignored. - Patterns follow gitignore semantics: glob matched against the path - relative to root. A leading slash anchors to root. A trailing slash - matches directories only (we match both dir and file for simplicity). + Walks upward from *root* towards the filesystem root, stopping at a + ``.git`` boundary. Lines starting with # are comments; blank lines ignored. """ - patterns: list[str] = [] + patterns: list[tuple[Path, str]] = [] current = root.resolve() while True: ignore_file = current / ".graphifyignore" @@ -284,7 +282,7 @@ def _load_graphifyignore(root: Path) -> list[str]: for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines(): line = line.strip() if line and not line.startswith("#"): - patterns.append(line) + patterns.append((current, line)) # Stop climbing once we've processed the git repo root if (current / ".git").exists(): break @@ -295,34 +293,44 @@ def _load_graphifyignore(root: Path) -> list[str]: return patterns -def _is_ignored(path: Path, root: Path, patterns: list[str]) -> bool: +def _is_ignored(path: Path, root: Path, patterns: list[tuple[Path, str]]) -> bool: """Return True if path matches any .graphifyignore pattern.""" if not patterns: return False - try: - rel = str(path.relative_to(root)) - except ValueError: - return False - rel = rel.replace(os.sep, "/") - parts = rel.split("/") - for pattern in patterns: - # Normalize: strip leading/trailing slashes for matching purposes - p = pattern.strip("/") - if not p: - continue - # Match against full relative path + + def _matches(rel: str, p: str) -> bool: + parts = rel.split("/") if fnmatch.fnmatch(rel, p): return True - # Match against filename alone if fnmatch.fnmatch(path.name, p): return True - # Match against any path segment or prefix - # e.g. "vendor" or "vendor/" should match "vendor/lib.py" for i, part in enumerate(parts): if fnmatch.fnmatch(part, p): return True if fnmatch.fnmatch("/".join(parts[:i + 1]), p): return True + return False + + for anchor, pattern in patterns: + p = pattern.strip("/") + if not p: + continue + # Try path relative to the scan root + try: + rel = str(path.relative_to(root)).replace(os.sep, "/") + if _matches(rel, p): + return True + except ValueError: + pass + # Also try relative to the anchor dir (the .graphifyignore's location), + # so patterns written at a parent level still fire when running on a subfolder + if anchor != root: + try: + rel_anchor = str(path.relative_to(anchor)).replace(os.sep, "/") + if _matches(rel_anchor, p): + return True + except ValueError: + pass return False diff --git a/graphify/security.py b/graphify/security.py index 8163805b..86446ef6 100644 --- a/graphify/security.py +++ b/graphify/security.py @@ -153,7 +153,13 @@ def validate_graph_path(path: str | Path, base: Path | None = None) -> Path: FileNotFoundError - resolved path does not exist """ if base is None: - base = Path("graphify-out").resolve() + resolved_hint = Path(path).resolve() + for candidate in [resolved_hint, *resolved_hint.parents]: + if candidate.name == "graphify-out": + base = candidate + break + if base is None: + base = Path("graphify-out").resolve() base = base.resolve() if not base.exists(): diff --git a/graphify/serve.py b/graphify/serve.py index 24723717..bd1a9484 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -50,7 +50,7 @@ def _score_nodes(G: nx.Graph, terms: list[str]) -> list[tuple[float, str]]: norm_terms = [_strip_diacritics(t).lower() for t in terms] for nid, data in G.nodes(data=True): norm_label = data.get("norm_label") or _strip_diacritics(data.get("label", "")).lower() - source = data.get("source_file", "").lower() + source = (data.get("source_file") or "").lower() score = sum(1 for t in norm_terms if t in norm_label) + sum(0.5 for t in norm_terms if t in source) if score > 0: scored.append((score, nid)) @@ -99,7 +99,8 @@ def _subgraph_to_text(G: nx.Graph, nodes: set[str], edges: list[tuple], token_bu lines.append(line) for u, v in edges: if u in nodes and v in nodes: - d = G.edges[u, v] + raw = G[u][v] + d = next(iter(raw.values()), {}) if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)) else raw line = f"EDGE {sanitize_label(G.nodes[u].get('label', u))} --{d.get('relation', '')} [{d.get('confidence', '')}]--> {sanitize_label(G.nodes[v].get('label', v))}" lines.append(line) output = "\n".join(lines) diff --git a/pyproject.toml b/pyproject.toml index 110216f9..e34afafc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.4.9" +version = "0.4.11" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, Trae) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" }