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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
worked/**/*.html linguist-vendored=true
graphify-out/**/*.html linguist-vendored=true
*.html linguist-detectable=false
graphify-out/graph.json merge=graphify
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ for example `graphify claude install --project` or `graphify codex install --pro
| Claude Code (Windows) | `graphify install` (auto-detected) or `graphify install --platform windows` |
| CodeBuddy | `graphify install --platform codebuddy` |
| Codex | `graphify install --platform codex` |
| Jcode | `graphify jcode install` |
| OpenCode | `graphify install --platform opencode` |
| Kilo Code | `graphify install --platform kilo` |
| GitHub Copilot CLI | `graphify install --platform copilot` |
Expand Down
4 changes: 3 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
_install_codex_hook,
_install_gemini_hook,
_install_kilo_plugin,
_install_jcode_hook,
_install_opencode_plugin,
_install_skill_references,
_kilo_config_path,
Expand Down Expand Up @@ -85,6 +86,7 @@
_uninstall_codex_hook,
_uninstall_gemini_hook,
_uninstall_kilo_plugin,
_uninstall_jcode_hook,
_uninstall_opencode_plugin,
claude_install,
claude_uninstall,
Expand Down Expand Up @@ -507,7 +509,7 @@ def _run_cli() -> None:
print("Usage: graphify <command>")
print()
print("Commands:")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|jcode|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
print(" uninstall remove graphify from all detected platforms in one shot")
print(" --purge also delete graphify-out/ directory")
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
Expand Down
50 changes: 50 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,52 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None:
pass


def _run_jcode_hook_guard() -> bool:
"""Redirect Jcode's first raw code lookup to Graphify.

Jcode sends the tool input JSON on stdin, exports metadata through
``JCODE_HOOK_*``, and treats exit 2 plus stderr as a blocked tool call.
Return True only for the first matching raw lookup in a session; malformed
input and unsupported tools fail open.
"""
from graphify.paths import out_path

try:
if not out_path("graph.json").is_file() or _query_stamp_fresh():
return False
payload = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
if not isinstance(payload, dict):
return False

tool_name = os.environ.get("JCODE_HOOK_TOOL_NAME", "").strip().lower()
command = str(payload.get("command") or "")
command_lower = command.lower()
if "graphify query" in command_lower:
return False

is_raw_lookup = tool_name in {"agentgrep", "grep", "read"}
if tool_name == "bash":
is_raw_lookup = any(
token in command_lower
for token in ("grep", "ripgrep", "rg ", "find ", "fd ", "ack ", "ag ")
)
if not is_raw_lookup:
return False

session_id = os.environ.get("JCODE_HOOK_SESSION_ID", "").strip()
if not session_id or not _mark_session_denied(f"jcode-{session_id}"):
return False

sys.stderr.write(
"Graphify knowledge graph is available for this project. "
"Run `graphify query \"<your codebase question>\"` before raw "
"search/read, then retry this tool if more detail is needed.\n"
)
return True
except Exception:
return False


def _target_is_indexed(file_path: str, root: "Path") -> bool:
"""Guard the strict deny: only block a read of a file the graph actually indexes.
Reads manifest.json (cheap, capped); on any doubt (missing/corrupt/oversized
Expand Down Expand Up @@ -2147,6 +2193,10 @@ def dispatch_command(cmd: str) -> None:
strict="--strict" in sys.argv[3:],
)
sys.exit(0)
elif cmd == "jcode-hook":
# Jcode pre_tool gate: exit 2 blocks once and exposes stderr to the
# model; every unsupported/error path fails open with exit 0.
sys.exit(2 if _run_jcode_hook_guard() else 0)
elif cmd == "check-update":
if len(sys.argv) < 3:
print("Usage: graphify check-update <path>", file=sys.stderr)
Expand Down
132 changes: 132 additions & 0 deletions graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,13 @@ def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md")
"claude_md": False,
"skill_refs": "agents",
},
"jcode": {
# Jcode follows Agent Skills and discovers global skills here.
"skill_file": "skill-agents.md",
"skill_dst": Path(".jcode") / "skills" / "graphify" / "SKILL.md",
"claude_md": False,
"skill_refs": "agents",
},
"devin": {
# Monolith: devin ships the full SKILL.md inline, no references/ sidecar.
"skill_file": "skill-devin.md",
Expand Down Expand Up @@ -610,6 +617,9 @@ def install(platform: str = "claude", *, project: bool = False, project_dir: Pat
project_dir = project_dir or Path(".")
skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir)

if platform == "jcode" and not project:
_install_jcode_hook()

if platform == "kilo":
# Kilo Code also supports a native /graphify command file.
command_src = Path(__file__).parent / "command-kilo.md"
Expand Down Expand Up @@ -680,6 +690,113 @@ def _print_install_usage() -> None:
print(f"Platforms: {platforms}")
print(" --strict block the first raw file read per session until one "
"`graphify query` runs (Claude Code project hook only; needs --project)")


def _jcode_config_path() -> Path:
root = os.environ.get("JCODE_HOME")
return (Path(root) if root else Path.home() / ".jcode") / "config.toml"


def _render_toml_strings(values: list[str]) -> str:
encoded = [json.dumps(value, ensure_ascii=False) for value in values]
return encoded[0] if len(encoded) == 1 else "[" + ", ".join(encoded) + "]"


def _replace_jcode_pre_tool(config_text: str, values: list[str]) -> str:
"""Replace only hooks.pre_tool while preserving the rest of config.toml."""
import tomllib

parsed = tomllib.loads(config_text) if config_text.strip() else {}
if not isinstance(parsed.get("hooks", {}), dict):
raise ValueError("[hooks] must be a TOML table")

lines = config_text.splitlines()
section_start = next(
(i for i, line in enumerate(lines) if line.strip() == "[hooks]"), None
)
rendered = "pre_tool = " + _render_toml_strings(values) if values else ""
if section_start is None:
if not values:
return config_text
prefix = config_text.rstrip()
return (prefix + "\n\n" if prefix else "") + "[hooks]\n" + rendered + "\n"

section_end = next(
(i for i in range(section_start + 1, len(lines))
if lines[i].strip().startswith("[")),
len(lines),
)
assignment_start = next(
(i for i in range(section_start + 1, section_end)
if re.match(r"^\s*pre_tool\s*=", lines[i])),
None,
)
if assignment_start is None:
if values:
lines.insert(section_end, rendered)
else:
assignment_end = assignment_start + 1
for candidate_end in range(assignment_start + 1, section_end + 1):
snippet = "[hooks]\n" + "\n".join(lines[assignment_start:candidate_end])
try:
candidate = tomllib.loads(snippet).get("hooks", {}).get("pre_tool")
except tomllib.TOMLDecodeError:
continue
if isinstance(candidate, (str, list)):
assignment_end = candidate_end
break
lines[assignment_start:assignment_end] = [rendered] if values else []

result = "\n".join(lines)
return result + ("\n" if config_text.endswith("\n") or result else "")


def _jcode_hook_values(config_text: str) -> list[str]:
import tomllib

parsed = tomllib.loads(config_text) if config_text.strip() else {}
current = parsed.get("hooks", {}).get("pre_tool")
if current is None:
return []
if isinstance(current, str):
return [current]
if isinstance(current, list) and all(isinstance(value, str) for value in current):
return list(current)
raise ValueError("hooks.pre_tool must be a string or an array of strings")


def _install_jcode_hook() -> None:
config_path = _jcode_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
exe = _resolve_graphify_exe()
if " " in exe and not exe.startswith('"'):
exe = f'"{exe}"'
try:
values = [v for v in _jcode_hook_values(text) if "jcode-hook" not in v]
values.append(f"{exe} jcode-hook")
updated = _replace_jcode_pre_tool(text, values)
except Exception as exc:
print(f"error: cannot update {config_path}: {exc}", file=sys.stderr)
sys.exit(1)
config_path.write_text(updated, encoding="utf-8")
print(f" Jcode pre_tool -> registered in {config_path}")


def _uninstall_jcode_hook() -> None:
config_path = _jcode_config_path()
if not config_path.exists():
return
text = config_path.read_text(encoding="utf-8")
try:
values = [v for v in _jcode_hook_values(text) if "jcode-hook" not in v]
updated = _replace_jcode_pre_tool(text, values)
except Exception as exc:
print(f"error: cannot update {config_path}: {exc}", file=sys.stderr)
sys.exit(1)
if updated != text:
config_path.write_text(updated, encoding="utf-8")
print(f" Jcode pre_tool -> Graphify hook removed from {config_path}")
_CLAUDE_MD_MARKER = "## graphify"
_CODEBUDDY_MD_MARKER = "## graphify"
_AGENTS_MD_MARKER = "## graphify"
Expand Down Expand Up @@ -1798,6 +1915,8 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
# The generic agents platform's user-scope skill lives at ~/.agents/skills,
# which neither the AGENTS.md cleanup nor amp's removal reaches.
_remove_skill_file("agents")
_remove_skill_file("jcode")
_uninstall_jcode_hook()
_uninstall_opencode_plugin(pd)
_uninstall_codex_hook(pd)

Expand Down Expand Up @@ -2003,6 +2122,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals
"install",
"kilo",
"kiro",
"jcode",
"opencode",
"pi",
"skills",
Expand Down Expand Up @@ -2134,6 +2254,18 @@ def dispatch_install_cli(cmd: str) -> bool:
else:
print("Usage: graphify codebuddy [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "jcode":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
install(platform="jcode")
elif subcmd == "uninstall":
removed = _remove_skill_file("jcode")
if removed:
print("skill removed")
_uninstall_jcode_hook()
else:
print("Usage: graphify jcode [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "gemini":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
Expand Down
Loading