diff --git a/graphify/analyze.py b/graphify/analyze.py
index 8ca7ddac..50f85aca 100644
--- a/graphify/analyze.py
+++ b/graphify/analyze.py
@@ -109,19 +109,16 @@ def _is_concept_node(G: nx.Graph, node_id: str) -> bool:
return False
-_CODE_EXTENSIONS = {"py", "ts", "tsx", "js", "go", "rs", "java", "rb", "cpp", "c", "h", "cs", "kt", "scala", "php"}
-_DOC_EXTENSIONS = {"md", "txt", "rst"}
-_PAPER_EXTENSIONS = {"pdf"}
-_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "webp", "gif", "svg"}
+from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS
def _file_category(path: str) -> str:
- ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
- if ext in _CODE_EXTENSIONS:
+ ext = ("." + path.rsplit(".", 1)[-1].lower()) if "." in path else ""
+ if ext in CODE_EXTENSIONS:
return "code"
- if ext in _PAPER_EXTENSIONS:
+ if ext in PAPER_EXTENSIONS:
return "paper"
- if ext in _IMAGE_EXTENSIONS:
+ if ext in IMAGE_EXTENSIONS:
return "image"
return "doc"
diff --git a/graphify/cluster.py b/graphify/cluster.py
index 09d07040..363d555c 100644
--- a/graphify/cluster.py
+++ b/graphify/cluster.py
@@ -52,23 +52,6 @@ def _partition(G: nx.Graph) -> dict[str, int]:
return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
-def build_graph(nodes: list[dict], edges: list[dict]) -> nx.Graph:
- """Build a NetworkX graph from graphify node/edge dicts.
-
- Preserves original edge direction as _src/_tgt attributes so that
- display functions can show relationships in the correct direction,
- even though the graph is undirected for structural analysis.
- """
- G = nx.Graph()
- for n in nodes:
- G.add_node(n["id"], **{k: v for k, v in n.items() if k != "id"})
- for e in edges:
- attrs = {k: v for k, v in e.items() if k not in ("source", "target")}
- attrs["_src"] = e["source"]
- attrs["_tgt"] = e["target"]
- G.add_edge(e["source"], e["target"], **attrs)
- return G
-
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
diff --git a/graphify/export.py b/graphify/export.py
index d7a7349a..300f93ac 100644
--- a/graphify/export.py
+++ b/graphify/export.py
@@ -183,9 +183,28 @@ function focusNode(nodeId) {{
showInfo(nodeId);
}}
+// Track hovered node — hover detection is more reliable than click params
+let hoveredNodeId = null;
+network.on('hoverNode', params => {{
+ hoveredNodeId = params.node;
+ container.style.cursor = 'pointer';
+}});
+network.on('blurNode', () => {{
+ hoveredNodeId = null;
+ container.style.cursor = 'default';
+}});
+container.addEventListener('click', () => {{
+ if (hoveredNodeId !== null) {{
+ showInfo(hoveredNodeId);
+ network.selectNodes([hoveredNodeId]);
+ }}
+}});
network.on('click', params => {{
- if (params.nodes.length > 0) showInfo(params.nodes[0]);
- else document.getElementById('info-content').innerHTML = 'Click a node to inspect it';
+ if (params.nodes.length > 0) {{
+ showInfo(params.nodes[0]);
+ }} else if (hoveredNodeId === null) {{
+ document.getElementById('info-content').innerHTML = 'Click a node to inspect it';
+ }}
}});
const searchInput = document.getElementById('search');
diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md
index e635c020..e0955c61 100644
--- a/graphify/skill-windows.md
+++ b/graphify/skill-windows.md
@@ -637,7 +637,7 @@ cost_path.write_text(json.dumps(cost, indent=2))
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)')
"
-Remove-Item -ErrorAction SilentlyContinue .graphify_detect.json, .graphify_extract.json, .graphify_ast.json, .graphify_semantic.json, .graphify_analysis.json, .graphify_labels.json, .graphify_python
+Remove-Item -ErrorAction SilentlyContinue .graphify_detect.json, .graphify_extract.json, .graphify_ast.json, .graphify_semantic.json, .graphify_analysis.json, .graphify_labels.json
Remove-Item -ErrorAction SilentlyContinue graphify-out/.needs_update
```
diff --git a/graphify/skill.md b/graphify/skill.md
index ec09523d..e4049e4b 100644
--- a/graphify/skill.md
+++ b/graphify/skill.md
@@ -71,9 +71,9 @@ else
PYTHON="python3"
fi
"$PYTHON" -c "import graphify" 2>/dev/null || pip install graphifyy -q --break-system-packages 2>&1 | tail -3
-# Write interpreter path for all subsequent steps
-"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w').write(sys.executable)"
+# Write interpreter path for all subsequent steps (persists across invocations)
mkdir -p graphify-out
+"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w').write(sys.executable)"
```
If the import succeeds, print nothing and move straight to Step 2.
@@ -683,6 +683,24 @@ The graph is the map. Your job after the pipeline is to be the guide.
---
+## Interpreter guard for subcommands
+
+Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
+
+```bash
+if [ ! -f graphify-out/.graphify_python ]; then
+ GRAPHIFY_BIN=$(which graphify 2>/dev/null)
+ if [ -n "$GRAPHIFY_BIN" ]; then
+ PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
+ case "$PYTHON" in *[!a-zA-Z0-9/_.-]*) PYTHON="python3" ;; esac
+ else
+ PYTHON="python3"
+ fi
+ mkdir -p graphify-out
+ "$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w').write(sys.executable)"
+fi
+```
+
## For --update (incremental re-extraction)
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
diff --git a/graphify/watch.py b/graphify/watch.py
index 0d92bd6c..a0262527 100644
--- a/graphify/watch.py
+++ b/graphify/watch.py
@@ -5,17 +5,10 @@ import time
from pathlib import Path
-_WATCHED_EXTENSIONS = {
- ".py", ".ts", ".js", ".go", ".rs", ".java", ".cpp", ".c", ".rb", ".swift", ".kt",
- ".cs", ".scala", ".php", ".cc", ".cxx", ".hpp", ".h", ".kts",
- ".md", ".txt", ".rst", ".pdf",
- ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg",
-}
+from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS
-_CODE_EXTENSIONS = {
- ".py", ".ts", ".js", ".go", ".rs", ".java", ".cpp", ".c", ".rb", ".swift", ".kt",
- ".cs", ".scala", ".php", ".cc", ".cxx", ".hpp", ".h", ".kts",
-}
+_WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAGE_EXTENSIONS
+_CODE_EXTENSIONS = CODE_EXTENSIONS
def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
diff --git a/pyproject.toml b/pyproject.toml
index 3ba33a5f..9a1dac6d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
-version = "0.3.17"
+version = "0.3.18"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, OpenClaw) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
@@ -57,4 +57,4 @@ where = ["."]
include = ["graphify*"]
[tool.setuptools.package-data]
-graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-claw.md", "skill-windows.md", "skill-droid.md"]
+graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md"]
diff --git a/tests/test_analyze.py b/tests/test_analyze.py
index 0ae2ede5..2d496139 100644
--- a/tests/test_analyze.py
+++ b/tests/test_analyze.py
@@ -137,6 +137,15 @@ def test_file_category():
assert _file_category("flash.pdf") == "paper"
assert _file_category("diagram.png") == "image"
assert _file_category("notes.md") == "doc"
+ # Languages added in later releases — would misclassify as "doc" without detect.py import
+ assert _file_category("app.swift") == "code"
+ assert _file_category("plugin.lua") == "code"
+ assert _file_category("build.zig") == "code"
+ assert _file_category("deploy.ps1") == "code"
+ assert _file_category("server.ex") == "code"
+ assert _file_category("component.jsx") == "code"
+ assert _file_category("analysis.jl") == "code"
+ assert _file_category("view.m") == "code"
def test_is_concept_node_empty_source():
diff --git a/tests/test_install.py b/tests/test_install.py
index ac8f24b0..7715b4db 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -9,6 +9,10 @@ PLATFORMS = {
"codex": (".agents/skills/graphify/SKILL.md",),
"opencode": (".config/opencode/skills/graphify/SKILL.md",),
"claw": (".claw/skills/graphify/SKILL.md",),
+ "droid": (".factory/skills/graphify/SKILL.md",),
+ "trae": (".trae/skills/graphify/SKILL.md",),
+ "trae-cn": (".trae-cn/skills/graphify/SKILL.md",),
+ "windows": (".claude/skills/graphify/SKILL.md",),
}
@@ -38,6 +42,26 @@ def test_install_claw(tmp_path):
assert (tmp_path / ".claw" / "skills" / "graphify" / "SKILL.md").exists()
+def test_install_droid(tmp_path):
+ _install(tmp_path, "droid")
+ assert (tmp_path / ".factory" / "skills" / "graphify" / "SKILL.md").exists()
+
+
+def test_install_trae(tmp_path):
+ _install(tmp_path, "trae")
+ assert (tmp_path / ".trae" / "skills" / "graphify" / "SKILL.md").exists()
+
+
+def test_install_trae_cn(tmp_path):
+ _install(tmp_path, "trae-cn")
+ assert (tmp_path / ".trae-cn" / "skills" / "graphify" / "SKILL.md").exists()
+
+
+def test_install_windows(tmp_path):
+ _install(tmp_path, "windows")
+ assert (tmp_path / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
+
+
def test_install_unknown_platform_exits(tmp_path):
with pytest.raises(SystemExit):
_install(tmp_path, "unknown")
@@ -67,10 +91,10 @@ def test_claw_skill_is_sequential():
def test_all_skill_files_exist_in_package():
- """All four platform skill files must be present in the installed package."""
+ """All installable platform skill files must be present in the installed package."""
import graphify
pkg = Path(graphify.__file__).parent
- for name in ("skill.md", "skill-codex.md", "skill-opencode.md", "skill-claw.md"):
+ for name in ("skill.md", "skill-codex.md", "skill-opencode.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md"):
assert (pkg / name).exists(), f"Missing: {name}"