From 0286911f5f9cfdd050b45f21901d7257243bd489 Mon Sep 17 00:00:00 2001 From: Haoran Zhang Date: Fri, 24 Jul 2026 10:06:03 -0700 Subject: [PATCH] feat: add AdaL platform support --- README.md | 12 +- graphify/__main__.py | 5 + graphify/cli.py | 9 +- graphify/install.py | 277 +++++++++++++++++++++++++++++++++++-- graphify/skill-adal.md | 95 +++++++++++++ pyproject.toml | 2 +- tests/test_adal_install.py | 277 +++++++++++++++++++++++++++++++++++++ tests/test_hook_strict.py | 11 +- tests/test_install.py | 2 + 9 files changed, 661 insertions(+), 29 deletions(-) create mode 100644 graphify/skill-adal.md create mode 100644 tests/test_adal_install.py diff --git a/README.md b/README.md index 0459f57cf..fadd73c88 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ graphify-out/ └── graph.json the full graph — query it anytime without re-reading your files ``` -**Works in** Claude Code, Cursor, Codex, Gemini CLI, GitHub Copilot, and 15+ more — [pick your platform](#install). +**Works in** Claude Code, [AdaL](https://github.com/SylphAI-Inc/adal), Cursor, Codex, Gemini CLI, GitHub Copilot, and 15+ more — [pick your platform](#install). --- @@ -198,7 +198,7 @@ for example `graphify claude install --project` or `graphify codex install --pro > **Git hooks and uv tool / pipx:** `graphify hook install` embeds the current interpreter path directly into the hook scripts at install time, so the post-commit hook fires correctly even in GUI git clients and CI runners where `~/.local/bin` is not on PATH. If you reinstall or upgrade graphify, re-run `graphify hook install` to refresh the embedded path. -> **Strict mode (Claude Code):** `graphify install --project --strict` makes the assistant actually use the graph. The default install *nudges* it to run `graphify query` before reading files; strict mode *blocks* the first raw source read of a session and redirects it to the graph, then reverts to the nudge (so it fires at most once per session and never gets stuck). Toggle at runtime with `GRAPHIFY_HOOK_STRICT=1`/`0`; the default install is unchanged (soft nudge). +> **Strict mode (Claude Code and AdaL):** `graphify install --project --strict` (Claude Code) or `graphify adal install --strict` (AdaL) makes the assistant actually use the graph. The default install *nudges* it to run `graphify query` before reading files; strict mode *blocks* the first raw source read of a session and redirects it to the graph, then reverts to the nudge (so it fires at most once per session and never gets stuck). Toggle at runtime with `GRAPHIFY_HOOK_STRICT=1`/`0`; the default install is unchanged (soft nudge).
Pick your platform (20+ assistants, click to expand) @@ -207,6 +207,7 @@ for example `graphify claude install --project` or `graphify codex install --pro |----------|----------------| | Claude Code (Linux/Mac) | `graphify install` | | Claude Code (Windows) | `graphify install` (auto-detected) or `graphify install --platform windows` | +| [AdaL](https://github.com/SylphAI-Inc/adal) | `graphify install --platform adal` | | CodeBuddy | `graphify install --platform codebuddy` | | Codex | `graphify install --platform codex` | | OpenCode | `graphify install --platform opencode` | @@ -229,7 +230,7 @@ for example `graphify claude install --project` or `graphify codex install --pro | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | -Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. CodeBuddy uses the same Agent tool and PreToolUse hook mechanism as Claude Code. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support `PreToolUse` hooks, so AGENTS.md is the always-on mechanism. +Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. AdaL uses native lifecycle hooks for graph-first search/read reminders and a compact skill built on Graphify's CLI/query surface. CodeBuddy uses the same Agent tool and PreToolUse hook mechanism as Claude Code. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support `PreToolUse` hooks, so AGENTS.md is the always-on mechanism. `--platform agents` (alias `--platform skills`) targets the generic cross-framework [Agent-Skills](https://github.com/anthropics/skills) locations: the spec's user-global `~/.agents/skills/` (read by `npx skills` and spec-compliant frameworks) for a global install, and `./.agents/skills/` for a project (`--project`) install. The bare `graphify install` stays single-platform (Claude Code) by design — use the named `agents` platform when you want the skill discoverable by any framework that reads `.agents/skills`. @@ -276,6 +277,7 @@ Run this once in your project after building a graph: | Platform | Command | |----------|---------| | Claude Code | `graphify claude install` | +| [AdaL](https://github.com/SylphAI-Inc/adal) | `graphify adal install` | | CodeBuddy | `graphify codebuddy install` | | Codex | `graphify codex install` | | OpenCode | `graphify opencode install` | @@ -300,13 +302,15 @@ Run this once in your project after building a graph: This writes a small config file that tells your assistant to consult the knowledge graph for codebase questions, preferring scoped queries like `graphify query ""` over reading the full report or grepping raw files. -- **Hook platforms** (Claude Code, Gemini CLI): a hook fires automatically before search-style tool calls (and, on Claude Code, before reading source files one by one via the Read/Glob tools) and nudges your assistant toward the graph path. +- **Hook platforms** (Claude Code, AdaL, Gemini CLI): a hook fires automatically before search-style tool calls (and, on Claude Code and AdaL, before reading source files one by one) and nudges your assistant toward the graph path. - **Instruction-file platforms** (Codex, OpenCode, Cursor, etc.): persistent instruction files (`AGENTS.md`, `.cursor/rules/`, etc.) provide the same query-first guidance. `GRAPH_REPORT.md` is still available for broad architecture review. **CodeBuddy** does the same two things as Claude Code: writes a `CODEBUDDY.md` section telling CodeBuddy to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs `PreToolUse` hooks (`.codebuddy/settings.json`) that fire before Bash search commands and file reads, nudging toward `graphify query` instead. +**AdaL** installs the skill at `~/.adal/skills/graphify/SKILL.md` and merges two `PreToolUse` groups into `~/.adal/settings.json`: `bash|grep` calls use Graphify's search guard, and `read_file|glob` calls use its source-read guard. Existing settings and non-Graphify hooks are preserved. Because AdaL currently loads lifecycle hooks from user settings, `--project` installs the project skill and `AGENTS.md` guidance without changing the user hook configuration. + **Codex** writes to `AGENTS.md` and also installs a `PreToolUse` hook in `.codex/hooks.json` that fires before every Bash tool call, same always-on mechanism as Claude Code. **Kilo Code** installs the Graphify skill to `~/.config/kilo/skills/graphify/SKILL.md` and a native `/graphify` command to `~/.config/kilo/command/graphify.md`. `graphify kilo install` also writes `AGENTS.md` plus a native `tool.execute.before` plugin (`.kilo/plugins/graphify.js` + `.kilo/kilo.json` or `.kilo/kilo.jsonc` registration) so Kilo gets the same always-on graph reminder behavior through native `.kilo` config. diff --git a/graphify/__main__.py b/graphify/__main__.py index 924ae986d..27e9e751e 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -28,6 +28,9 @@ # `from graphify.__main__ import ` keeps working unchanged. from graphify.install import ( # noqa: E402,F401 dispatch_install_cli, + _adal_install, + _adal_pretooluse_hooks, + _adal_uninstall, _agents_install, _agents_platform_install, _agents_platform_uninstall, @@ -48,6 +51,7 @@ _devin_rules_uninstall, _gemini_hook, _install_claude_hook, + _install_adal_hook, _install_codebuddy_hook, _install_codex_hook, _install_gemini_hook, @@ -81,6 +85,7 @@ _strip_graphify_md_section, _strip_json_comments, _uninstall_claude_hook, + _uninstall_adal_hook, _uninstall_codebuddy_hook, _uninstall_codex_hook, _uninstall_gemini_hook, diff --git a/graphify/cli.py b/graphify/cli.py index 91df09672..015c11f5e 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -410,10 +410,11 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None: Fails open everywhere: any error, or a non-matching tool call, prints nothing and the caller exits 0, so a legitimate tool call is never blocked by a bug. - In strict mode (opt-in, Claude Code Read only) the FIRST raw read of indexed, - in-project, fresh code per session is DENIED with a redirect to `graphify query` - (permissionDecision), then downgrades to the soft nudge — it fires at most once - per session and can never strand the agent. Search (Bash) and Glob stay + In strict mode (opt-in, Claude Code or AdaL source reads) the FIRST raw read + of indexed, in-project, fresh code per session is DENIED with a redirect to + `graphify query` (permissionDecision), then downgrades to the soft nudge — it + fires at most once per session and can never strand the agent. Search (Bash) + and Glob stay nudge-only: a compound shell command has no single parseable target and blocking file listing would strand navigation. #1840: reads of out-of-project files are ignored, and a graph that is stale for the target file softens to a non-mandatory diff --git a/graphify/install.py b/graphify/install.py index 1a8d3e3d3..2a268b31e 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -16,6 +16,7 @@ import os import platform import re +import shlex import shutil import sys from pathlib import Path @@ -300,8 +301,9 @@ def _claude_pretooluse_hooks(strict: bool = False) -> "list[dict]": Bash-only matcher never fired on the agent's primary search path. When ``strict`` is set, the read hook carries ``--strict`` so it blocks the - first raw read per session (Claude Code only). The ``GRAPHIFY_HOOK_STRICT`` env - var can force it on or off at runtime without a reinstall. + first raw read per session. Claude Code and AdaL both understand the guard's + deny response. The ``GRAPHIFY_HOOK_STRICT`` env var can force it on or off + at runtime without a reinstall. """ exe = _resolve_graphify_exe() if " " in exe and not exe.startswith('"'): @@ -328,6 +330,13 @@ def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") "claude_md": True, "skill_refs": "claude", }, + "adal": { + # Thin AdaL adapter: the skill delegates to Graphify's stable CLI/query + # surface. Native semantic orchestration can be added independently. + "skill_file": "skill-adal.md", + "skill_dst": Path(".adal") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + }, "codex": { "skill_file": "skill-codex.md", "skill_dst": Path(".codex") / "skills" / "graphify" / "SKILL.md", @@ -586,7 +595,13 @@ def _print_banner() -> None: """) except Exception: pass -def install(platform: str = "claude", *, project: bool = False, project_dir: Path | None = None) -> None: +def install( + platform: str = "claude", + *, + project: bool = False, + project_dir: Path | None = None, + strict: bool = False, +) -> None: _print_banner() platform = _canonical_platform(platform) if platform == "gemini": @@ -605,10 +620,20 @@ def install(platform: str = "claude", *, project: bool = False, project_dir: Pat ) sys.exit(1) + if platform == "adal" and not project: + # Fail before copying the skill if the shared settings file cannot be + # safely merged. Never leave a half-installed AdaL integration. + _load_adal_settings(Path.home() / ".adal" / "settings.json") + cfg = _PLATFORM_CONFIG[platform] project_dir = project_dir or Path(".") skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir) + if platform == "adal" and not project: + # AdaL reads lifecycle hooks from the user-level settings file only. + # Project installs remain project-scoped and use AGENTS.md instead. + _install_adal_hook(strict=strict) + if platform == "kilo": # Kilo Code also supports a native /graphify command file. command_src = Path(__file__).parent / "command-kilo.md" @@ -675,7 +700,7 @@ def _print_install_usage() -> None: print("Usage: graphify install [--project] [--strict] [--platform P|P]") 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)") + "`graphify query` runs (Claude Code project hook or AdaL user hook)") _CLAUDE_MD_MARKER = "## graphify" _CODEBUDDY_MD_MARKER = "## graphify" _AGENTS_MD_MARKER = "## graphify" @@ -1359,6 +1384,207 @@ def _resolve_graphify_exe() -> str: found = str(candidate) break return (found or "graphify").replace("\\", "/") + + +def _adal_pretooluse_hooks(strict: bool = False) -> list[dict]: + """Return Graphify hooks using AdaL's native lowercase tool names.""" + exe = _resolve_graphify_exe() + if " " in exe and not exe.startswith('"'): + exe = f'"{exe}"' + read_cmd = f"{exe} hook-guard read" + (" --strict" if strict else "") + return [ + { + "matcher": "bash|grep", + "hooks": [ + {"type": "command", "command": f"{exe} hook-guard search"} + ], + }, + { + "matcher": "read_file|glob", + "hooks": [{"type": "command", "command": read_cmd}], + }, + ] + + +def _is_adal_graphify_hook(group: object) -> bool: + """Whether a matcher group is one of the lifecycle hooks Graphify owns.""" + if not isinstance(group, dict): + return False + matcher = group.get("matcher") + if matcher not in ("bash|grep", "read_file|glob"): + return False + hooks = group.get("hooks") + if not isinstance(hooks, list) or len(hooks) != 1: + return False + hook = hooks[0] + if not isinstance(hook, dict) or hook.get("type") != "command": + return False + command = hook.get("command") + if not isinstance(command, str): + return False + try: + parts = shlex.split(command, posix=True) + except ValueError: + return False + if len(parts) not in (3, 4): + return False + executable = Path(parts[0].replace("\\", "/")).name.casefold() + if executable not in ("graphify", "graphify.exe") or parts[1] != "hook-guard": + return False + expected_kind = "search" if matcher == "bash|grep" else "read" + if parts[2] != expected_kind: + return False + return len(parts) == 3 or ( + expected_kind == "read" and parts[3] == "--strict" + ) + + +def _load_adal_settings(settings_path: Path) -> dict: + """Load AdaL's shared settings without risking destructive replacement.""" + if not settings_path.exists(): + return {} + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + print( + f"error: cannot update {settings_path}: {exc}", + file=sys.stderr, + ) + raise SystemExit(1) from exc + if not isinstance(settings, dict): + print( + f"error: cannot update {settings_path}: top-level JSON must be an object", + file=sys.stderr, + ) + raise SystemExit(1) + hooks = settings.get("hooks") + if hooks is not None and not isinstance(hooks, dict): + print( + f"error: cannot update {settings_path}: 'hooks' must be an object", + file=sys.stderr, + ) + raise SystemExit(1) + pre_tool = hooks.get("PreToolUse") if isinstance(hooks, dict) else None + if pre_tool is not None and not isinstance(pre_tool, list): + print( + f"error: cannot update {settings_path}: hooks.PreToolUse must be a list", + file=sys.stderr, + ) + raise SystemExit(1) + return settings + + +def _write_adal_settings(settings_path: Path, settings: dict) -> None: + """Atomically write AdaL's shared settings file.""" + settings_path.parent.mkdir(parents=True, exist_ok=True) + staged = settings_path.with_suffix(settings_path.suffix + ".tmp") + mode = ( + settings_path.stat().st_mode & 0o777 + if settings_path.exists() + else 0o600 + ) + try: + staged.write_text( + json.dumps(settings, indent=2) + "\n", + encoding="utf-8", + ) + try: + os.chmod(staged, mode) + except OSError: + pass + os.replace(staged, settings_path) + except Exception: + try: + staged.unlink(missing_ok=True) + except OSError: + pass + raise + + +def _install_adal_hook(strict: bool = False) -> None: + """Merge Graphify's hooks into AdaL's user-level settings.json.""" + settings_path = Path.home() / ".adal" / "settings.json" + settings = _load_adal_settings(settings_path) + hooks = settings.setdefault("hooks", {}) + pre_tool = hooks.setdefault("PreToolUse", []) + hooks["PreToolUse"] = [ + group for group in pre_tool if not _is_adal_graphify_hook(group) + ] + hooks["PreToolUse"].extend(_adal_pretooluse_hooks(strict=strict)) + _write_adal_settings(settings_path, settings) + mode = " (strict)" if strict else "" + print( + " ~/.adal/settings.json -> PreToolUse hooks registered " + f"(bash/grep search + read_file/glob){mode}" + ) + + +def _uninstall_adal_hook() -> None: + """Remove only Graphify-owned hooks from AdaL's shared settings.json.""" + settings_path = Path.home() / ".adal" / "settings.json" + if not settings_path.exists(): + return + settings = _load_adal_settings(settings_path) + hooks = settings.get("hooks") + if not isinstance(hooks, dict): + return + pre_tool = hooks.get("PreToolUse") + if not isinstance(pre_tool, list): + return + filtered = [ + group for group in pre_tool if not _is_adal_graphify_hook(group) + ] + if len(filtered) == len(pre_tool): + return + if filtered: + hooks["PreToolUse"] = filtered + else: + hooks.pop("PreToolUse", None) + if not hooks: + settings.pop("hooks", None) + _write_adal_settings(settings_path, settings) + print(" ~/.adal/settings.json -> PreToolUse hooks removed") + + +def _adal_install( + project_dir: Path | None = None, + *, + project: bool = False, + strict: bool = False, +) -> None: + """Install AdaL's skill plus native hook/AGENTS.md integration.""" + project_dir = project_dir or Path(".") + if project: + if strict: + print( + "error: AdaL loads hooks only from ~/.adal/settings.json; " + "run `graphify adal install --strict` for strict mode", + file=sys.stderr, + ) + raise SystemExit(1) + _project_install("adal", project_dir) + return + install("adal", project_dir=project_dir, strict=strict) + _agents_install(project_dir, "adal") + + +def _adal_uninstall( + project_dir: Path | None = None, + *, + project: bool = False, +) -> None: + """Remove AdaL's project or user integration, preserving unrelated hooks.""" + project_dir = project_dir or Path(".") + if project: + _project_uninstall("adal", project_dir) + return + removed = _remove_skill_file("adal") + if removed: + print("skill removed") + _agents_uninstall(project_dir, platform="adal") + _uninstall_adal_hook() + + def _install_codex_hook(project_dir: Path) -> None: """Add graphify PreToolUse hook to .codex/hooks.json.""" hooks_path = project_dir / ".codex" / "hooks.json" @@ -1429,16 +1655,13 @@ def _agents_install(project_dir: Path, platform: str) -> None: _install_kilo_plugin(project_dir or Path(".")) print() - print( - f"{platform.capitalize()} will now check the knowledge graph before answering" - ) + display_name = "AdaL" if platform == "adal" else platform.capitalize() + print(f"{display_name} will now check the knowledge graph before answering") print("codebase questions and rebuild it after code changes.") - if platform not in ("codex", "opencode", "kilo"): + if platform not in ("adal", "codex", "opencode", "kilo"): print() print("Note: unlike Claude Code, there is no PreToolUse hook equivalent for") - print( - f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism." - ) + print(f"{display_name} — the AGENTS.md rules are the always-on mechanism.") def _amp_legacy_cleanup() -> None: """Best-effort removal of the pre-fix ~/.amp/skills/graphify install dir. @@ -1496,7 +1719,14 @@ def _project_install(platform_name: str, project_dir: Path | None = None, strict elif platform_name == "kiro": _kiro_install(project_dir) _print_project_git_add_hint([project_dir / ".kiro"]) - elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): + elif platform_name == "adal" and strict: + print( + "error: AdaL loads hooks only from ~/.adal/settings.json; " + "run `graphify adal install --strict` for strict mode", + file=sys.stderr, + ) + raise SystemExit(1) + elif platform_name in ("adal", "aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir) _agents_install(project_dir, platform_name) hint_paths = [_project_scope_root(skill_dst, project_dir), project_dir / "AGENTS.md"] @@ -1537,7 +1767,7 @@ def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> N _cursor_uninstall(project_dir) elif platform_name == "kiro": _kiro_uninstall(project_dir) - elif platform_name in ("aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): + elif platform_name in ("adal", "aider", "amp", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): _remove_skill_file(platform_name, project=True, project_dir=project_dir) _agents_uninstall(project_dir, platform=platform_name) if platform_name == "codex": @@ -1732,8 +1962,10 @@ 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("adal") _uninstall_opencode_plugin(pd) _uninstall_codex_hook(pd) + _uninstall_adal_hook() # Git hook try: @@ -1899,6 +2131,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals _CLI_INSTALL_COMMANDS = frozenset({ + "adal", "agents", "aider", "amp", @@ -1983,13 +2216,16 @@ def dispatch_install_cli(cmd: str) -> bool: if project_scope: _project_install(chosen_platform, Path("."), strict=strict) else: - if strict: + if strict and _canonical_platform(chosen_platform) != "adal": print( "note: --strict applies to the project PreToolUse hook; run " "`graphify install --project --strict` or `graphify claude install --strict`.", file=sys.stderr, ) - install(platform=chosen_platform) + install( + platform=chosen_platform, + strict=(strict and _canonical_platform(chosen_platform) == "adal"), + ) elif cmd == "uninstall": args = sys.argv[2:] purge = "--purge" in args @@ -2038,6 +2274,17 @@ def dispatch_install_cli(cmd: str) -> bool: else: print("Usage: graphify claude [install|uninstall]", file=sys.stderr) sys.exit(1) + elif cmd == "adal": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + project_scope = "--project" in sys.argv[3:] + strict = "--strict" in sys.argv[3:] + if subcmd == "install": + _adal_install(Path("."), project=project_scope, strict=strict) + elif subcmd == "uninstall": + _adal_uninstall(Path("."), project=project_scope) + else: + print("Usage: graphify adal [install|uninstall]", file=sys.stderr) + sys.exit(1) elif cmd == "codebuddy": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": diff --git a/graphify/skill-adal.md b/graphify/skill-adal.md new file mode 100644 index 000000000..1e714c321 --- /dev/null +++ b/graphify/skill-adal.md @@ -0,0 +1,95 @@ +--- +name: graphify +description: "Use for codebase and architecture questions when graphify-out/ exists, and for building or updating a Graphify knowledge graph from a project." +--- + +# Graphify for AdaL + +Use Graphify as the project map before broad raw-file exploration. + +## Existing graph: query first + +If `graphify-out/graph.json` exists and the user asks how the project works, +start with the smallest relevant command: + +```bash +graphify query "" +graphify explain "" +graphify path "" "" +``` + +Use raw search and file reads after the graph has identified the relevant +components, or when exact source lines are needed for implementation or +debugging. Use `GRAPH_REPORT.md` only for broad architecture review. + +## Build a graph + +If Graphify is not installed, install it with: + +```bash +uv tool install graphifyy +``` + +For a code repository, build the structural graph without an API key: + +```bash +graphify extract --code-only +``` + +For a mixed corpus containing documents, papers, or images, `graphify extract` +can use a configured semantic backend. Check `graphify extract --help`; do not +ask the user to add an API key unless they explicitly want semantic extraction. + +Graphify writes its outputs under `graphify-out/`, including `graph.json` and +`GRAPH_REPORT.md`. + +## Keep the graph current + +After code changes, refresh the graph: + +```bash +graphify update . +``` + +For automatic AST-only refreshes after commits: + +```bash +graphify hook install +``` + +## AdaL integration + +`graphify adal install` installs this skill, adds Graphify guidance to the +project's `AGENTS.md`, and registers AdaL `PreToolUse` guards in +`~/.adal/settings.json`. The guards nudge broad search and source reads toward +the graph without changing unrelated AdaL settings. + +Strict mode blocks only the first raw source read in a session, then falls back +to a nudge: + +```bash +graphify adal install --strict +``` + +Project-scoped installation writes `.adal/skills/graphify/SKILL.md` and +`AGENTS.md` without changing user-level hook settings: + +```bash +graphify adal install --project +``` + +Remove the corresponding integration with: + +```bash +graphify adal uninstall +graphify adal uninstall --project +``` + +## Guardrails + +- Do not invent relationships that are absent from Graphify output. +- Cite `source_file` and `source_location` when Graphify returns them. +- Do not rebuild an existing graph for a question that `query`, `explain`, or + `path` can answer. +- Run `graphify update .` after modifying code when the graph should remain + current. diff --git a/pyproject.toml b/pyproject.toml index 1c46f149d..b4b20ae63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ include-package-data = false # under graphify/skills//references/, and the always-on injection blocks # under graphify/always_on/. There is no graphify/skills//SKILL.md in the # repo, so no SKILL.md glob is needed here. -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] +graphify = ["skill.md", "skill-adal.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/test_adal_install.py b/tests/test_adal_install.py new file mode 100644 index 000000000..c89d55289 --- /dev/null +++ b/tests/test_adal_install.py @@ -0,0 +1,277 @@ +"""AdaL skill and lifecycle-hook integration tests.""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +from unittest.mock import patch + +import pytest + +import graphify.install as installmod +from graphify.__main__ import main + + +def _settings(home: Path) -> Path: + return home / ".adal" / "settings.json" + + +def _graphify_groups(settings: dict) -> list[dict]: + return [ + group + for group in settings["hooks"]["PreToolUse"] + if installmod._is_adal_graphify_hook(group) + ] + + +def test_adal_hook_install_is_safe_and_idempotent(tmp_path): + home = tmp_path / "home" + settings_path = _settings(home) + settings_path.parent.mkdir(parents=True) + lookalike = { + "matcher": "bash", + "hooks": [ + {"type": "command", "command": "echo graphify hook-guard search"} + ], + } + post_group = { + "matcher": "*", + "hooks": [{"type": "command", "command": "post-audit"}], + } + settings_path.write_text( + json.dumps( + { + "theme": "dark", + "hooks": { + "PreToolUse": [lookalike], + "PostToolUse": [post_group], + }, + } + ), + encoding="utf-8", + ) + settings_path.chmod(0o640) + + with ( + patch("graphify.install.Path.home", return_value=home), + patch( + "graphify.install._resolve_graphify_exe", + return_value="/opt/Graphify Tools/graphify", + ), + ): + installmod._install_adal_hook(strict=True) + installmod._install_adal_hook(strict=True) + + installed = json.loads(settings_path.read_text(encoding="utf-8")) + assert installed["theme"] == "dark" + assert settings_path.stat().st_mode & 0o777 == 0o640 + assert installed["hooks"]["PostToolUse"] == [post_group] + assert lookalike in installed["hooks"]["PreToolUse"] + groups = _graphify_groups(installed) + assert {group["matcher"] for group in groups} == { + "bash|grep", + "read_file|glob", + } + assert len(groups) == 2 + commands = [group["hooks"][0]["command"] for group in groups] + assert any(command.endswith("hook-guard search") for command in commands) + assert any(command.endswith("hook-guard read --strict") for command in commands) + + +def test_adal_hook_uninstall_removes_only_owned_groups(tmp_path): + home = tmp_path / "home" + settings_path = _settings(home) + settings_path.parent.mkdir(parents=True) + lookalike = { + "matcher": "bash|grep", + "hooks": [ + {"type": "command", "command": "echo graphify hook-guard search"} + ], + } + owned = [ + { + "matcher": "bash|grep", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/graphify hook-guard search", + } + ], + }, + { + "matcher": "read_file|glob", + "hooks": [ + { + "type": "command", + "command": "/usr/local/bin/graphify hook-guard read --strict", + } + ], + }, + ] + original = { + "model": "example", + "hooks": { + "PreToolUse": [lookalike, *owned], + "Stop": [{"hooks": [{"type": "command", "command": "keep-stop"}]}], + }, + } + settings_path.write_text(json.dumps(original), encoding="utf-8") + + with patch("graphify.install.Path.home", return_value=home): + installmod._uninstall_adal_hook() + + remaining = json.loads(settings_path.read_text(encoding="utf-8")) + assert remaining["model"] == "example" + assert remaining["hooks"]["Stop"] == original["hooks"]["Stop"] + assert remaining["hooks"]["PreToolUse"] == [lookalike] + + +def test_adal_hook_install_refuses_malformed_settings(tmp_path): + home = tmp_path / "home" + settings_path = _settings(home) + settings_path.parent.mkdir(parents=True) + settings_path.write_text("{broken", encoding="utf-8") + + with patch("graphify.install.Path.home", return_value=home): + with pytest.raises(SystemExit): + installmod._install_adal_hook() + + assert settings_path.read_text(encoding="utf-8") == "{broken" + assert not settings_path.with_suffix(".json.tmp").exists() + + +def test_install_platform_adal_writes_thin_skill_and_hooks( + tmp_path, monkeypatch +): + home = tmp_path / "home" + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + monkeypatch.setattr(sys, "argv", ["graphify", "install", "--platform", "adal"]) + monkeypatch.setattr( + installmod, + "_resolve_graphify_exe", + lambda: "/usr/local/bin/graphify", + ) + + with patch("graphify.install.Path.home", return_value=home): + main() + + skill_dir = home / ".adal" / "skills" / "graphify" + skill = skill_dir / "SKILL.md" + assert skill.exists() + assert not (skill_dir / "references").exists() + content = skill.read_text(encoding="utf-8") + assert "graphify query" in content + assert all( + unsupported not in content + for unsupported in ("Task(", "Agent(", "spawn_agent", "agentic_search") + ) + assert len(_graphify_groups(json.loads(_settings(home).read_text()))) == 2 + assert not (project / "AGENTS.md").exists() + + +def test_adal_user_subcommand_roundtrip(tmp_path, monkeypatch): + home = tmp_path / "home" + project = tmp_path / "project" + project.mkdir() + agents_path = project / "AGENTS.md" + agents_path.write_text("# Team rules\n\nKeep this.\n", encoding="utf-8") + monkeypatch.chdir(project) + monkeypatch.setattr( + installmod, + "_resolve_graphify_exe", + lambda: "/usr/local/bin/graphify", + ) + + with patch("graphify.install.Path.home", return_value=home): + monkeypatch.setattr(sys, "argv", ["graphify", "adal", "install"]) + main() + assert (home / ".adal" / "skills" / "graphify" / "SKILL.md").exists() + assert "## graphify" in agents_path.read_text(encoding="utf-8") + + monkeypatch.setattr(sys, "argv", ["graphify", "adal", "uninstall"]) + main() + + assert not (home / ".adal" / "skills" / "graphify").exists() + assert agents_path.read_text(encoding="utf-8") == "# Team rules\n\nKeep this.\n" + assert json.loads(_settings(home).read_text(encoding="utf-8")) == {} + + +def test_adal_project_roundtrip_stays_project_scoped(tmp_path, monkeypatch): + home = tmp_path / "home" + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + + with patch("graphify.install.Path.home", return_value=home): + monkeypatch.setattr( + sys, "argv", ["graphify", "adal", "install", "--project"] + ) + main() + + skill_dir = project / ".adal" / "skills" / "graphify" + assert (skill_dir / "SKILL.md").exists() + assert not (skill_dir / "references").exists() + assert "## graphify" in (project / "AGENTS.md").read_text(encoding="utf-8") + assert not (home / ".adal").exists() + + with patch("graphify.install.Path.home", return_value=home): + monkeypatch.setattr( + sys, "argv", ["graphify", "adal", "uninstall", "--project"] + ) + main() + + assert not skill_dir.exists() + assert not (project / "AGENTS.md").exists() + + +def test_adal_project_strict_requires_user_scope(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + monkeypatch.setattr( + sys, + "argv", + ["graphify", "adal", "install", "--project", "--strict"], + ) + + with pytest.raises(SystemExit): + main() + + assert not (project / ".adal").exists() + assert not (project / "AGENTS.md").exists() + + +def test_adal_hook_payload_nudges_on_search(tmp_path): + (tmp_path / "graphify-out").mkdir() + (tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") + payload = { + "session_id": "adal-session", + "cwd": str(tmp_path), + "hook_event_name": "PreToolUse", + "tool_name": "bash", + "tool_input": {"command": "rg lifecycle_hooks src"}, + } + env = dict(os.environ) + env.pop("GRAPHIFY_OUT", None) + repo_root = str(Path(__file__).resolve().parents[1]) + env["PYTHONPATH"] = os.pathsep.join( + part for part in (repo_root, env.get("PYTHONPATH", "")) if part + ) + result = subprocess.run( + [sys.executable, "-m", "graphify", "hook-guard", "search"], + input=json.dumps(payload), + capture_output=True, + encoding="utf-8", + errors="replace", + cwd=tmp_path, + env=env, + ) + + assert result.returncode == 0 + output = json.loads(result.stdout) + assert output["hookSpecificOutput"]["hookEventName"] == "PreToolUse" + assert "graphify query" in output["hookSpecificOutput"]["additionalContext"] diff --git a/tests/test_hook_strict.py b/tests/test_hook_strict.py index 1b34d13f9..2d70f2ddd 100644 --- a/tests/test_hook_strict.py +++ b/tests/test_hook_strict.py @@ -1,10 +1,11 @@ """Strict-mode hook-guard: opt-in block-then-nudge + #1840 gating. -The strict guard (Claude Code Read only) denies the FIRST raw read of an indexed, -in-project, fresh source file per session, then downgrades to the soft nudge — so -it can never strand an agent. #1840: out-of-project reads are ignored and a graph -that is stale for the target softens to a non-mandatory nudge. Everything defaults -to the historical soft nudge unless strict is explicitly enabled. +The strict guard (Claude Code or AdaL source reads) denies the FIRST raw read of +an indexed, in-project, fresh source file per session, then downgrades to the +soft nudge — so it can never strand an agent. #1840: out-of-project reads are +ignored and a graph that is stale for the target softens to a non-mandatory +nudge. Everything defaults to the historical soft nudge unless strict is +explicitly enabled. """ import io import json diff --git a/tests/test_install.py b/tests/test_install.py index ac4497cff..5f78571d7 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -7,6 +7,7 @@ PLATFORMS = { + "adal": (".adal/skills/graphify/SKILL.md",), "claude": (".claude/skills/graphify/SKILL.md",), "codebuddy": (".codebuddy/skills/graphify/SKILL.md",), "codex": (".codex/skills/graphify/SKILL.md",), @@ -310,6 +311,7 @@ def test_all_skill_files_exist_in_package(): pkg = Path(graphify.__file__).parent for name in ( "skill.md", + "skill-adal.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md",