mirror of
https://github.com/Graphify-Labs/graphify.git
synced 2026-09-14 19:34:09 +08:00
784b1fb1bd
Fixes #3508's other reported failure: the Kiro skill install died on
the same WinError 17 while replacing SKILL.md. Covers the version
stamp write too, which shares the same pattern beside it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit e6ac02a49d)
1457 lines
57 KiB
Python
1457 lines
57 KiB
Python
"""Tests for graphify install --platform routing."""
|
||
import os
|
||
from pathlib import Path
|
||
import sys
|
||
from unittest.mock import patch
|
||
import pytest
|
||
|
||
|
||
PLATFORMS = {
|
||
"claude": (".claude/skills/graphify/SKILL.md",),
|
||
"codebuddy": (".codebuddy/skills/graphify/SKILL.md",),
|
||
"codex": (".codex/skills/graphify/SKILL.md",),
|
||
"opencode": (".config/opencode/skills/graphify/SKILL.md",),
|
||
"kilo": (
|
||
".config/kilo/skills/graphify/SKILL.md",
|
||
".config/kilo/command/graphify.md",
|
||
),
|
||
"claw": (".openclaw/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",),
|
||
}
|
||
|
||
|
||
def _install(tmp_path, platform):
|
||
from graphify.__main__ import install
|
||
|
||
old_cwd = Path.cwd()
|
||
try:
|
||
os.chdir(tmp_path)
|
||
with patch("graphify.__main__.Path.home", return_value=tmp_path):
|
||
install(platform=platform)
|
||
finally:
|
||
os.chdir(old_cwd)
|
||
|
||
|
||
def test_install_default_claude(tmp_path):
|
||
_install(tmp_path, "claude")
|
||
assert (tmp_path / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_install_survives_a_winerror_17_replace(tmp_path, monkeypatch):
|
||
"""#3508: installing SKILL.md failed on some Windows setups with WinError
|
||
17 ("cannot move to a different disk drive") from `os.replace`, even with
|
||
the temp file and destination in the same directory on the same drive.
|
||
WinError 17 is a plain OSError, not PermissionError, so the install's
|
||
atomic replace must fall back to copy-then-delete for it too.
|
||
|
||
Uses "aider" (a monolith platform, no references/ sidecar) so the only
|
||
os.replace this install performs is the SKILL.md file replace under test
|
||
-- a progressive platform's separate directory replace for references/
|
||
isn't covered by the same fallback and would fail this test for an
|
||
unrelated reason.
|
||
"""
|
||
real_replace = os.replace
|
||
|
||
def flaky_replace(src, dst):
|
||
exc = OSError("cannot move to a different disk drive")
|
||
exc.winerror = 17
|
||
raise exc
|
||
|
||
monkeypatch.setattr(os, "replace", flaky_replace)
|
||
try:
|
||
_install(tmp_path, "aider")
|
||
finally:
|
||
monkeypatch.setattr(os, "replace", real_replace)
|
||
|
||
skill = tmp_path / ".aider" / "graphify" / "SKILL.md"
|
||
assert skill.exists()
|
||
assert not any(p.name.endswith(".tmp") for p in skill.parent.iterdir())
|
||
|
||
|
||
def test_install_claude_md_honors_claude_config_dir(tmp_path, monkeypatch):
|
||
"""#2694: with CLAUDE_CONFIG_DIR set, the always-on registration lands in
|
||
$CLAUDE_CONFIG_DIR/CLAUDE.md — not the default ~/.claude/CLAUDE.md, which the
|
||
old code mutated regardless of the relocated profile."""
|
||
from graphify.__main__ import install
|
||
|
||
home = tmp_path / "home"
|
||
home.mkdir()
|
||
config = tmp_path / "cfg"
|
||
config.mkdir()
|
||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(config))
|
||
old = os.getcwd()
|
||
try:
|
||
os.chdir(tmp_path)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
install(platform="claude")
|
||
finally:
|
||
os.chdir(old)
|
||
|
||
cfg_md = config / "CLAUDE.md"
|
||
assert cfg_md.exists(), "registration did not land in $CLAUDE_CONFIG_DIR"
|
||
text = cfg_md.read_text()
|
||
assert "# graphify" in text
|
||
assert str(config) in text, "skill reference does not point into the config dir"
|
||
assert not (home / ".claude" / "CLAUDE.md").exists(), "default profile was mutated"
|
||
|
||
|
||
def test_install_claude_md_defaults_to_home_when_config_dir_unset(tmp_path, monkeypatch):
|
||
"""Env unset: behavior is unchanged — the block lands in ~/.claude/CLAUDE.md
|
||
with the tilde skill reference."""
|
||
from graphify.__main__ import install
|
||
|
||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||
old = os.getcwd()
|
||
try:
|
||
os.chdir(tmp_path)
|
||
with patch("graphify.__main__.Path.home", return_value=tmp_path):
|
||
install(platform="claude")
|
||
finally:
|
||
os.chdir(old)
|
||
|
||
md = tmp_path / ".claude" / "CLAUDE.md"
|
||
assert md.exists()
|
||
assert "~/.claude/skills/graphify/SKILL.md" in md.read_text()
|
||
|
||
|
||
def _deny_writes_to(target: Path, monkeypatch):
|
||
"""Make write_text raise PermissionError for *target* only (simulates a
|
||
dotfile symlinked into a read-only store, e.g. /nix/store)."""
|
||
real_write_text = Path.write_text
|
||
|
||
def guarded(self, *args, **kwargs):
|
||
if self == target:
|
||
raise PermissionError(13, "Permission denied", str(self))
|
||
return real_write_text(self, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(Path, "write_text", guarded)
|
||
|
||
|
||
def test_install_survives_unwritable_claude_md(tmp_path, monkeypatch, capsys):
|
||
"""#3474: a read-only ~/.claude/CLAUDE.md must not abort the install.
|
||
|
||
install() copies the skill files first and registers the always-on block
|
||
afterwards, so an unguarded write left a half-completed install plus a
|
||
traceback on nix/home-manager, chezmoi and stow-with-read-only-sources.
|
||
"""
|
||
from graphify.__main__ import install
|
||
|
||
home = tmp_path / "home"
|
||
home.mkdir()
|
||
target = home / ".claude" / "CLAUDE.md"
|
||
target.parent.mkdir(parents=True)
|
||
target.write_text("# my rules\n")
|
||
|
||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||
monkeypatch.chdir(tmp_path)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
_deny_writes_to(target, monkeypatch)
|
||
install(platform="claude") # must not raise
|
||
|
||
assert (home / ".claude" / "skills" / "graphify" / "SKILL.md").exists(), (
|
||
"skill files should still be installed"
|
||
)
|
||
assert target.read_text() == "# my rules\n", "unwritable file must be untouched"
|
||
err = capsys.readouterr().err
|
||
assert "skipped" in err
|
||
assert "PermissionError" in err
|
||
|
||
|
||
def test_install_survives_unwritable_codebuddy_md(tmp_path, monkeypatch, capsys):
|
||
"""#3474 (same shape): an unwritable CODEBUDDY.md must not abort the install."""
|
||
from graphify.__main__ import install
|
||
|
||
home = tmp_path / "home"
|
||
home.mkdir()
|
||
target = home / ".codebuddy" / "CODEBUDDY.md"
|
||
target.parent.mkdir(parents=True)
|
||
target.write_text("# my rules\n")
|
||
|
||
monkeypatch.chdir(tmp_path)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
_deny_writes_to(target, monkeypatch)
|
||
install(platform="codebuddy") # must not raise
|
||
|
||
assert (home / ".codebuddy" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert target.read_text() == "# my rules\n"
|
||
assert "skipped" in capsys.readouterr().err
|
||
|
||
|
||
def test_install_claude_md_success_output_unchanged(tmp_path, monkeypatch, capsys):
|
||
"""Regression guard: the writable path still reports the same messages."""
|
||
from graphify.__main__ import install
|
||
|
||
home = tmp_path / "home"
|
||
home.mkdir()
|
||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||
monkeypatch.chdir(tmp_path)
|
||
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
install(platform="claude")
|
||
first = capsys.readouterr().out
|
||
install(platform="claude")
|
||
second = capsys.readouterr().out
|
||
|
||
assert " CLAUDE.md -> created at " in first
|
||
assert " CLAUDE.md -> already registered (no change)" in second
|
||
|
||
|
||
def test_install_codebuddy(tmp_path):
|
||
_install(tmp_path, "codebuddy")
|
||
assert (tmp_path / ".codebuddy" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_install_codex(tmp_path):
|
||
_install(tmp_path, "codex")
|
||
assert (tmp_path / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_install_opencode(tmp_path):
|
||
_install(tmp_path, "opencode")
|
||
assert (
|
||
tmp_path / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md"
|
||
).exists()
|
||
|
||
|
||
def test_install_positional_platform_opencode(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
monkeypatch.chdir(tmp_path)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "install", "opencode"])
|
||
with patch("graphify.__main__.Path.home", return_value=tmp_path):
|
||
main()
|
||
assert (tmp_path / ".config" / "opencode" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (tmp_path / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_install_project_claude_writes_project_scope(tmp_path, monkeypatch, capsys):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "install", "--project"])
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
main()
|
||
assert (project / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert (project / ".claude" / "CLAUDE.md").exists()
|
||
assert not (home / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert ".claude/skills/graphify/SKILL.md" in (project / ".claude" / "CLAUDE.md").read_text()
|
||
assert "~/.claude/skills/graphify/SKILL.md" not in (project / ".claude" / "CLAUDE.md").read_text()
|
||
assert "git add .claude/" in capsys.readouterr().out
|
||
|
||
|
||
def test_install_project_codex_writes_skill_and_agents(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "install", "--project", "--platform", "codex"])
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
main()
|
||
assert (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert (project / "AGENTS.md").exists()
|
||
assert (project / ".codex" / "hooks.json").exists()
|
||
assert not (home / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_claude_subcommand_project_install_and_uninstall_are_project_scoped(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
user_skill = home / ".claude" / "skills" / "graphify" / "SKILL.md"
|
||
user_skill.parent.mkdir(parents=True)
|
||
user_skill.write_text("user skill")
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "claude", "install", "--project"])
|
||
main()
|
||
assert (project / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert (project / ".claude" / "CLAUDE.md").exists()
|
||
assert (project / "CLAUDE.md").exists()
|
||
assert user_skill.exists()
|
||
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "claude", "uninstall", "--project"])
|
||
main()
|
||
|
||
assert user_skill.exists()
|
||
assert not (project / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (project / ".claude" / "CLAUDE.md").exists()
|
||
assert not (project / "CLAUDE.md").exists()
|
||
|
||
|
||
def test_codex_subcommand_project_install_and_uninstall_are_project_scoped(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
user_skill = home / ".codex" / "skills" / "graphify" / "SKILL.md"
|
||
user_skill.parent.mkdir(parents=True)
|
||
user_skill.write_text("user skill")
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "codex", "install", "--project"])
|
||
main()
|
||
assert (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert (project / "AGENTS.md").exists()
|
||
assert (project / ".codex" / "hooks.json").exists()
|
||
assert user_skill.exists()
|
||
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "codex", "uninstall", "--project"])
|
||
main()
|
||
|
||
assert user_skill.exists()
|
||
assert not (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (project / "AGENTS.md").exists()
|
||
hooks_path = project / ".codex" / "hooks.json"
|
||
assert hooks_path.exists()
|
||
assert "graphify" not in hooks_path.read_text()
|
||
|
||
|
||
def test_antigravity_install_project_writes_project_skill(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "install", "--project"])
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
main()
|
||
assert (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (home / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_install_help_does_not_install_default(tmp_path, monkeypatch, capsys):
|
||
from graphify.__main__ import main
|
||
monkeypatch.chdir(tmp_path)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "install", "opencode", "--help"])
|
||
with patch("graphify.__main__.Path.home", return_value=tmp_path):
|
||
main()
|
||
out = capsys.readouterr().out
|
||
assert "Usage: graphify install" in out
|
||
assert "opencode" in out
|
||
assert not (tmp_path / ".claude").exists()
|
||
assert not (tmp_path / ".config").exists()
|
||
|
||
|
||
def test_install_claw(tmp_path):
|
||
_install(tmp_path, "claw")
|
||
assert (tmp_path / ".openclaw" / "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")
|
||
|
||
|
||
def test_codex_skill_contains_spawn_agent():
|
||
"""Codex skill file must reference spawn_agent."""
|
||
import graphify
|
||
|
||
skill = (Path(graphify.__file__).parent / "skill-codex.md").read_text()
|
||
assert "spawn_agent" in skill
|
||
|
||
|
||
def test_codex_skill_uses_graphify_with_existing_graph():
|
||
"""Codex skill must keep graph-first orientation in the lean-core split.
|
||
|
||
The progressive-disclosure split drops codex's old monolith-only "dirty
|
||
graph output" blurb; the graph-first intent now lives in the shared core's
|
||
fast-path block, which jumps straight to the query flow when a graph exists.
|
||
"""
|
||
import graphify
|
||
skill = (Path(graphify.__file__).parent / "skill-codex.md").read_text()
|
||
assert "Fast path — existing graph" in skill
|
||
assert "skip Steps 1–5 entirely and jump straight to `## For /graphify query`" in skill
|
||
assert "graphify query" in skill
|
||
assert "graphify explain" in skill
|
||
assert "graphify path" in skill
|
||
|
||
|
||
def test_codex_agents_install_mentions_dirty_graph_output(tmp_path):
|
||
_agents_install(tmp_path, "codex")
|
||
content = (tmp_path / "AGENTS.md").read_text()
|
||
assert "Dirty graphify-out/ files are expected" in content
|
||
assert "not a reason to skip graphify" in content
|
||
|
||
|
||
def test_opencode_skill_contains_mention():
|
||
"""OpenCode skill file must reference @mention."""
|
||
import graphify
|
||
|
||
skill = (Path(graphify.__file__).parent / "skill-opencode.md").read_text()
|
||
assert "@mention" in skill
|
||
|
||
|
||
def test_opencode_skill_uses_opencode_agent_guidance():
|
||
"""OpenCode's dispatch slot uses @mention, not the Claude Agent-tool example.
|
||
|
||
The progressive split consolidates the bespoke v8 opencode prose into the
|
||
shared core. opencode's distinguishing delta is the @mention dispatch block;
|
||
its B2 slot must carry that and must NOT carry the Claude Agent-tool example.
|
||
(The shared Step B3 re-run hint names the general-purpose agent type as the
|
||
canonical example for every host; that lives in the shared core, not in
|
||
opencode's dispatch slot.)
|
||
"""
|
||
import graphify
|
||
|
||
skill = (Path(graphify.__file__).parent / "skill-opencode.md").read_text()
|
||
assert "@mention" in skill
|
||
assert "@agent" in skill
|
||
# Scope the agent-type check to opencode's dispatch slot (B2 -> B3).
|
||
b2 = skill[skill.index("**Step B2"):skill.index("**Step B3")]
|
||
assert "general-purpose" not in b2
|
||
assert "Concrete example for 3 chunks" not in b2
|
||
assert "OpenCode platform" in b2
|
||
|
||
|
||
def test_kilo_skill_mentions_task_tool():
|
||
"""Kilo skill file should use the native Task tool flow."""
|
||
import graphify
|
||
|
||
skill = (Path(graphify.__file__).parent / "skill-kilo.md").read_text()
|
||
assert "Task" in skill
|
||
|
||
|
||
def test_kilo_skill_avoids_double_quoted_python_c_fstring_dict_keys():
|
||
"""Kilo runs snippets through double-quoted python -c strings."""
|
||
import re
|
||
import graphify
|
||
|
||
skill = (Path(graphify.__file__).parent / "skill-kilo.md").read_text()
|
||
assert not re.search(r"print\(f'.*\[[\"'][^\"']+[\"']\]", skill)
|
||
|
||
|
||
def test_claw_skill_uses_agent_tool_dispatch():
|
||
"""OpenClaw rides the shared Agent-tool disk-collect dispatch.
|
||
|
||
The consolidated design moves claw off the v8 sequential OpenClaw flow onto
|
||
the same agent-tool-disk dispatch as claude (per-platform-deltas), so its B2
|
||
slot uses the Agent tool and must not carry the Codex or OpenCode mechanics.
|
||
"""
|
||
import graphify
|
||
|
||
skill = (Path(graphify.__file__).parent / "skill-claw.md").read_text()
|
||
b2 = skill[skill.index("**Step B2"):skill.index("**Step B3")]
|
||
assert 'subagent_type="general-purpose"' in b2
|
||
assert "spawn_agent" not in skill
|
||
assert "@mention" not in skill
|
||
|
||
|
||
def test_all_skill_files_exist_in_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-kilo.md",
|
||
"skill-claw.md",
|
||
"skill-windows.md",
|
||
"skill-droid.md",
|
||
"skill-trae.md",
|
||
"skill-kiro.md",
|
||
):
|
||
assert (pkg / name).exists(), f"Missing: {name}"
|
||
|
||
|
||
def test_kilo_command_file_exists_in_package():
|
||
import graphify
|
||
|
||
pkg = Path(graphify.__file__).parent
|
||
assert (pkg / "command-kilo.md").exists()
|
||
|
||
|
||
def test_claude_install_registers_claude_md(tmp_path):
|
||
"""Claude platform install writes CLAUDE.md; others do not."""
|
||
_install(tmp_path, "claude")
|
||
assert (tmp_path / ".claude" / "CLAUDE.md").exists()
|
||
|
||
|
||
def test_codex_install_does_not_write_claude_md(tmp_path):
|
||
_install(tmp_path, "codex")
|
||
assert not (tmp_path / ".claude" / "CLAUDE.md").exists()
|
||
|
||
|
||
# --- CodeBuddy CODEBUDDY.md + hook install/uninstall tests ---
|
||
|
||
def test_codebuddy_install_writes_codebuddy_md(tmp_path):
|
||
from graphify.__main__ import codebuddy_install
|
||
codebuddy_install(tmp_path)
|
||
md = tmp_path / "CODEBUDDY.md"
|
||
assert md.exists()
|
||
assert "graphify-out/GRAPH_REPORT.md" in md.read_text()
|
||
|
||
|
||
def test_codebuddy_install_writes_hook(tmp_path):
|
||
import json as _json
|
||
from graphify.__main__ import codebuddy_install
|
||
codebuddy_install(tmp_path)
|
||
settings = _json.loads((tmp_path / ".codebuddy" / "settings.json").read_text())
|
||
hooks = settings["hooks"]["PreToolUse"]
|
||
assert any("graphify" in str(h) for h in hooks)
|
||
|
||
|
||
def test_claude_hook_is_shell_agnostic(tmp_path):
|
||
# #522: the installed PreToolUse hooks must be plain exe invocations, not
|
||
# POSIX bash (which fails on Windows cmd.exe/PowerShell).
|
||
import json as _json
|
||
from graphify.__main__ import _install_claude_hook
|
||
_install_claude_hook(tmp_path)
|
||
hooks = _json.loads((tmp_path / ".claude" / "settings.json").read_text())["hooks"]["PreToolUse"]
|
||
matchers = {h["matcher"] for h in hooks}
|
||
assert {"Bash|Grep", "Read|Glob"} <= matchers # Grep in the search matcher: #1986
|
||
for h in hooks:
|
||
cmd = h["hooks"][0]["command"]
|
||
for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"):
|
||
assert token not in cmd, f"shell syntax {token!r} in {cmd!r}"
|
||
assert "graphify" in cmd and "hook-guard" in cmd
|
||
|
||
|
||
def test_claude_hook_install_idempotent_and_replaces_old_bash_hook(tmp_path):
|
||
import json as _json
|
||
from graphify.__main__ import _install_claude_hook
|
||
settings_path = tmp_path / ".claude" / "settings.json"
|
||
settings_path.parent.mkdir(parents=True)
|
||
# Pre-seed a legacy bash-style graphify hook (the thing #522 shipped before).
|
||
settings_path.write_text(_json.dumps({"hooks": {"PreToolUse": [
|
||
{"matcher": "Bash", "hooks": [{"type": "command",
|
||
"command": "[ -f graphify-out/graph.json ] && echo '{...}' || true"}]},
|
||
]}}), encoding="utf-8")
|
||
_install_claude_hook(tmp_path)
|
||
_install_claude_hook(tmp_path) # second install must not duplicate
|
||
hooks = _json.loads(settings_path.read_text())["hooks"]["PreToolUse"]
|
||
graphify_hooks = [h for h in hooks if "graphify" in str(h)]
|
||
assert len(graphify_hooks) == 2, "exactly the Bash + Read|Glob guards, no dupes"
|
||
# the legacy bash payload must be gone
|
||
assert not any("[ -f graphify-out" in h["hooks"][0]["command"] for h in graphify_hooks)
|
||
|
||
|
||
def test_codebuddy_install_idempotent(tmp_path):
|
||
from graphify.__main__ import codebuddy_install
|
||
codebuddy_install(tmp_path)
|
||
codebuddy_install(tmp_path)
|
||
md = tmp_path / "CODEBUDDY.md"
|
||
assert md.read_text().count("## graphify") == 1
|
||
|
||
|
||
def test_codebuddy_install_merges_existing_codebuddy_md(tmp_path):
|
||
from graphify.__main__ import codebuddy_install
|
||
(tmp_path / "CODEBUDDY.md").write_text("# My project rules\n")
|
||
codebuddy_install(tmp_path)
|
||
content = (tmp_path / "CODEBUDDY.md").read_text()
|
||
assert "# My project rules" in content
|
||
assert "graphify-out/GRAPH_REPORT.md" in content
|
||
|
||
|
||
def test_codebuddy_uninstall_removes_section(tmp_path):
|
||
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
|
||
codebuddy_install(tmp_path)
|
||
codebuddy_uninstall(tmp_path)
|
||
md = tmp_path / "CODEBUDDY.md"
|
||
assert not md.exists()
|
||
|
||
|
||
def test_codebuddy_uninstall_removes_hook(tmp_path):
|
||
import json as _json
|
||
from graphify.__main__ import codebuddy_install, codebuddy_uninstall
|
||
codebuddy_install(tmp_path)
|
||
codebuddy_uninstall(tmp_path)
|
||
settings_path = tmp_path / ".codebuddy" / "settings.json"
|
||
if settings_path.exists():
|
||
settings = _json.loads(settings_path.read_text())
|
||
hooks = settings.get("hooks", {}).get("PreToolUse", [])
|
||
assert not any("graphify" in str(h) for h in hooks)
|
||
|
||
|
||
def test_codebuddy_uninstall_noop_if_not_installed(tmp_path):
|
||
from graphify.__main__ import codebuddy_uninstall
|
||
codebuddy_uninstall(tmp_path) # should not raise
|
||
|
||
|
||
def test_uninstall_project_removes_project_skill_only(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
user_skill = home / ".codex" / "skills" / "graphify" / "SKILL.md"
|
||
user_skill.parent.mkdir(parents=True)
|
||
user_skill.write_text("user skill")
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "install", "--project", "--platform", "codex"])
|
||
main()
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "uninstall", "--project", "--platform", "codex"])
|
||
main()
|
||
assert user_skill.exists()
|
||
assert not (project / ".codex" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (project / "AGENTS.md").exists()
|
||
|
||
|
||
def test_uninstall_project_without_platform_removes_project_installs(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
user_skill = home / ".claude" / "skills" / "graphify" / "SKILL.md"
|
||
user_skill.parent.mkdir(parents=True)
|
||
user_skill.write_text("user skill")
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "install", "--project"])
|
||
main()
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "uninstall", "--project"])
|
||
main()
|
||
assert user_skill.exists()
|
||
assert not (project / ".claude" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (project / ".claude" / "CLAUDE.md").exists()
|
||
|
||
|
||
def test_antigravity_uninstall_project_removes_project_skill_only(tmp_path, monkeypatch):
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
# Global skill lives at ~/.gemini/config/skills/ (per #1079 fix)
|
||
global_skill = home / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
|
||
global_skill.parent.mkdir(parents=True)
|
||
global_skill.write_text("global skill")
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "install", "--project"])
|
||
main()
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "uninstall", "--project"])
|
||
main()
|
||
assert global_skill.exists(), "project uninstall must not touch global skill"
|
||
assert not (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_antigravity_global_install_writes_gemini_config_skills(tmp_path, monkeypatch):
|
||
"""Global `graphify antigravity install` must write to ~/.gemini/config/skills/ (#1079)."""
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "install"])
|
||
main()
|
||
global_skill = home / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
|
||
wrong_skill = home / ".agents" / "skills" / "graphify" / "SKILL.md"
|
||
assert global_skill.exists(), f"skill missing from correct global path {global_skill}"
|
||
assert not wrong_skill.exists(), f"skill incorrectly written to {wrong_skill}"
|
||
# rules + workflow go workspace-local, not in home
|
||
assert (project / ".agents" / "rules" / "graphify.md").exists()
|
||
assert (project / ".agents" / "workflows" / "graphify.md").exists()
|
||
|
||
|
||
def test_antigravity_global_uninstall_removes_gemini_config_skill(tmp_path, monkeypatch):
|
||
"""Global `graphify antigravity uninstall` must remove from ~/.gemini/config/skills/ (#1079)."""
|
||
from graphify.__main__ import main
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "install"])
|
||
main()
|
||
global_skill = home / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
|
||
assert global_skill.exists(), "precondition: skill must exist before uninstall"
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "uninstall"])
|
||
main()
|
||
assert not global_skill.exists(), f"skill not removed from {global_skill} after uninstall"
|
||
# workspace files also cleaned up
|
||
assert not (project / ".agents" / "rules" / "graphify.md").exists()
|
||
assert not (project / ".agents" / "workflows" / "graphify.md").exists()
|
||
|
||
|
||
# --- always-on AGENTS.md install/uninstall tests ---
|
||
|
||
|
||
def _agents_install(tmp_path, platform):
|
||
from graphify.__main__ import _agents_install as _install_fn
|
||
|
||
_install_fn(tmp_path, platform)
|
||
|
||
|
||
def _agents_uninstall(tmp_path, platform=""):
|
||
from graphify.__main__ import _agents_uninstall as _uninstall_fn
|
||
|
||
_uninstall_fn(tmp_path, platform=platform)
|
||
|
||
|
||
def _kilo_install(project_dir, home_dir):
|
||
from graphify.__main__ import _kilo_install as _install_fn
|
||
|
||
with patch("graphify.__main__.Path.home", return_value=home_dir):
|
||
_install_fn(project_dir)
|
||
|
||
|
||
def _kilo_uninstall(project_dir, home_dir):
|
||
from graphify.__main__ import _kilo_uninstall as _uninstall_fn
|
||
|
||
with patch("graphify.__main__.Path.home", return_value=home_dir):
|
||
_uninstall_fn(project_dir)
|
||
|
||
|
||
def test_codex_agents_install_writes_agents_md(tmp_path):
|
||
_agents_install(tmp_path, "codex")
|
||
agents_md = tmp_path / "AGENTS.md"
|
||
assert agents_md.exists()
|
||
assert "graphify" in agents_md.read_text()
|
||
assert "GRAPH_REPORT.md" in agents_md.read_text()
|
||
|
||
|
||
def test_opencode_agents_install_writes_agents_md(tmp_path):
|
||
_agents_install(tmp_path, "opencode")
|
||
assert (tmp_path / "AGENTS.md").exists()
|
||
|
||
|
||
def test_claw_agents_install_writes_agents_md(tmp_path):
|
||
_agents_install(tmp_path, "claw")
|
||
assert (tmp_path / "AGENTS.md").exists()
|
||
|
||
|
||
def test_agents_install_idempotent(tmp_path):
|
||
"""Installing twice does not duplicate the section."""
|
||
_agents_install(tmp_path, "codex")
|
||
_agents_install(tmp_path, "codex")
|
||
content = (tmp_path / "AGENTS.md").read_text()
|
||
assert content.count("## graphify") == 1
|
||
|
||
|
||
def test_agents_install_appends_to_existing(tmp_path):
|
||
"""Installs into an existing AGENTS.md without overwriting other content."""
|
||
agents_md = tmp_path / "AGENTS.md"
|
||
agents_md.write_text("# Existing rules\n\nDo not break things.\n")
|
||
_agents_install(tmp_path, "codex")
|
||
content = agents_md.read_text()
|
||
assert "Do not break things." in content
|
||
assert "## graphify" in content
|
||
|
||
|
||
def test_agents_uninstall_removes_section(tmp_path):
|
||
_agents_install(tmp_path, "codex")
|
||
_agents_uninstall(tmp_path)
|
||
agents_md = tmp_path / "AGENTS.md"
|
||
# File deleted when it only contained graphify section
|
||
assert not agents_md.exists()
|
||
|
||
|
||
def test_agents_uninstall_preserves_other_content(tmp_path):
|
||
"""Uninstall keeps pre-existing content."""
|
||
agents_md = tmp_path / "AGENTS.md"
|
||
agents_md.write_text("# Existing rules\n\nDo not break things.\n")
|
||
_agents_install(tmp_path, "codex")
|
||
_agents_uninstall(tmp_path)
|
||
assert agents_md.exists()
|
||
content = agents_md.read_text()
|
||
assert "Do not break things." in content
|
||
assert "## graphify" not in content
|
||
|
||
|
||
def test_agents_uninstall_no_op_when_not_installed(tmp_path, capsys):
|
||
_agents_uninstall(tmp_path)
|
||
out = capsys.readouterr().out
|
||
assert "nothing to do" in out
|
||
|
||
|
||
def test_remove_marker_section_matches_exact_heading_only(tmp_path):
|
||
"""#2062: the strip helper must match graphify's own `## graphify` heading
|
||
exactly, never a substring inside a user's `### graphify` H3."""
|
||
from graphify.install import _remove_marker_section
|
||
m = "## graphify"
|
||
|
||
# Only a user H3 mention -> no exact marker line -> None (file left untouched).
|
||
assert _remove_marker_section("# Doc\n\n### graphify\n\nmy notes\n", m) is None
|
||
# An inline/bullet mention is likewise not a section.
|
||
assert _remove_marker_section("see the ## graphify bullet\n", m) is None
|
||
|
||
# A real H2 section alongside a user H3: remove only the H2 section.
|
||
content = "# Doc\n\n### graphify\n\nmy notes\n\n## graphify\n\ngraphify stuff\n"
|
||
out = _remove_marker_section(content, m)
|
||
assert out is not None
|
||
assert "### graphify" in out and "my notes" in out
|
||
assert not any(l.strip() == "## graphify" for l in out.splitlines())
|
||
assert "graphify stuff" not in out
|
||
|
||
# The section runs to the next H2 (not stopping at a `###` inside it).
|
||
c2 = "## graphify\n\nintro\n\n### sub\n\ninner\n\n## Keep\n\nkeep me\n"
|
||
out2 = _remove_marker_section(c2, m)
|
||
assert "## Keep" in out2 and "keep me" in out2
|
||
assert "inner" not in out2 and "intro" not in out2
|
||
|
||
|
||
def test_agents_uninstall_preserves_user_h3_graphify_heading(tmp_path):
|
||
"""#2062 end-to-end: uninstall strips graphify's own H2 section but leaves a
|
||
user-authored `### graphify` H3 (and everything else) byte-intact."""
|
||
agents_md = tmp_path / "AGENTS.md"
|
||
agents_md.write_text(
|
||
"# My rules\n\n"
|
||
"### graphify\n\n"
|
||
"My own notes on how I use graphify. Keep this.\n\n"
|
||
"## Other\n\nUnrelated content.\n"
|
||
)
|
||
_agents_install(tmp_path, "codex") # appends a genuine `## graphify` H2 section
|
||
assert "## graphify" in agents_md.read_text()
|
||
|
||
_agents_uninstall(tmp_path)
|
||
content = agents_md.read_text()
|
||
assert "### graphify" in content, "user's H3 heading was deleted (#2062)"
|
||
assert "My own notes on how I use graphify. Keep this." in content
|
||
assert "## Other" in content and "Unrelated content." in content
|
||
assert not any(l.strip() == "## graphify" for l in content.splitlines())
|
||
|
||
|
||
def test_uninstall_untouched_when_only_user_h3_present(tmp_path, capsys):
|
||
"""#2062: a file with only a user `### graphify` H3 (graphify never installed)
|
||
must be left byte-identical, not stripped."""
|
||
agents_md = tmp_path / "AGENTS.md"
|
||
original = "# My rules\n\n### graphify\n\nHand-written. Do not touch.\n"
|
||
agents_md.write_text(original)
|
||
before = agents_md.read_bytes()
|
||
_agents_uninstall(tmp_path)
|
||
assert agents_md.read_bytes() == before
|
||
assert "nothing to do" in capsys.readouterr().out
|
||
|
||
|
||
# --- OpenCode plugin tests ---
|
||
|
||
|
||
def test_opencode_agents_install_writes_plugin(tmp_path):
|
||
"""opencode install writes .opencode/plugins/graphify.js."""
|
||
_agents_install(tmp_path, "opencode")
|
||
plugin = tmp_path / ".opencode" / "plugins" / "graphify.js"
|
||
assert plugin.exists()
|
||
assert "tool.execute.before" in plugin.read_text()
|
||
|
||
|
||
def test_opencode_plugin_reminder_has_no_backticks(tmp_path):
|
||
"""The bash reminder string must not contain backticks or $(...) (regression test for #1413).
|
||
|
||
The plugin prepends `echo "<reminder>" && <cmd>` to the user's bash command.
|
||
Backticks or $() inside the reminder trigger bash command substitution
|
||
when the echo runs, which both corrupts tool output and silently executes
|
||
the very graphify command we are only suggesting.
|
||
"""
|
||
_agents_install(tmp_path, "opencode")
|
||
plugin = tmp_path / ".opencode" / "plugins" / "graphify.js"
|
||
body = plugin.read_text()
|
||
# Extract the echoed reminder string literal between the double-quotes
|
||
# of the `output.args.command = 'echo "..." && ' +` line.
|
||
import re
|
||
|
||
m = re.search(r'echo "([^"]*)"', body)
|
||
assert m, "echo reminder not found in plugin body"
|
||
reminder = m.group(1)
|
||
assert "`" not in reminder, f"backtick in reminder would trigger command substitution: {reminder!r}"
|
||
assert "$(" not in reminder, f"$() in reminder would trigger command substitution: {reminder!r}"
|
||
|
||
|
||
def test_opencode_plugin_uses_semicolon_not_ampersand(tmp_path):
|
||
"""The reminder must be joined to the user's command with ';', not '&&'
|
||
(#1646). Windows PowerShell 5.1 rejects '&&' as a statement separator, which
|
||
broke the first bash command of every OpenCode session on Windows. ';' works
|
||
in PowerShell 5.1, Bash, and POSIX shells."""
|
||
_agents_install(tmp_path, "opencode")
|
||
body = (tmp_path / ".opencode" / "plugins" / "graphify.js").read_text()
|
||
# The prepend line ends with the separator before `' +`.
|
||
assert '" ; \' +' in body or '." ; \' +' in body, "reminder should join with ';'"
|
||
assert '" && \' +' not in body, "'&&' breaks PowerShell 5.1 (#1646)"
|
||
|
||
|
||
def test_opencode_agents_install_registers_plugin_in_config(tmp_path):
|
||
"""opencode install registers the plugin in .opencode/opencode.json."""
|
||
_agents_install(tmp_path, "opencode")
|
||
config_file = tmp_path / ".opencode" / "opencode.json"
|
||
assert config_file.exists()
|
||
import json as _json
|
||
|
||
config = _json.loads(config_file.read_text())
|
||
assert any("graphify.js" in p for p in config.get("plugin", []))
|
||
|
||
|
||
def test_opencode_agents_install_merges_existing_config(tmp_path):
|
||
"""opencode install preserves existing .opencode/opencode.json keys."""
|
||
import json as _json
|
||
|
||
config_file = tmp_path / ".opencode" / "opencode.json"
|
||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||
config_file.write_text(_json.dumps({"model": "claude-opus-4-5", "plugin": []}))
|
||
_agents_install(tmp_path, "opencode")
|
||
config = _json.loads(config_file.read_text())
|
||
assert config["model"] == "claude-opus-4-5"
|
||
assert any("graphify.js" in p for p in config["plugin"])
|
||
|
||
|
||
def test_opencode_agents_uninstall_removes_plugin(tmp_path):
|
||
"""opencode uninstall removes the plugin file and deregisters from opencode.json."""
|
||
import json as _json
|
||
|
||
_agents_install(tmp_path, "opencode")
|
||
_agents_uninstall(tmp_path, platform="opencode")
|
||
plugin = tmp_path / ".opencode" / "plugins" / "graphify.js"
|
||
assert not plugin.exists()
|
||
config_file = tmp_path / ".opencode" / "opencode.json"
|
||
if config_file.exists():
|
||
config = _json.loads(config_file.read_text())
|
||
assert not any("graphify.js" in p for p in config.get("plugin", []))
|
||
|
||
|
||
def test_kilo_agents_install_writes_agents_md(tmp_path):
|
||
_agents_install(tmp_path, "kilo")
|
||
assert (tmp_path / "AGENTS.md").exists()
|
||
|
||
|
||
def test_kilo_agents_install_writes_plugin(tmp_path):
|
||
_agents_install(tmp_path, "kilo")
|
||
plugin = tmp_path / ".kilo" / "plugins" / "graphify.js"
|
||
assert plugin.exists()
|
||
assert "tool.execute.before" in plugin.read_text()
|
||
|
||
|
||
def test_kilo_agents_install_registers_plugin_in_config(tmp_path):
|
||
import json as _json
|
||
|
||
_agents_install(tmp_path, "kilo")
|
||
config_file = tmp_path / ".kilo" / "kilo.json"
|
||
assert config_file.exists()
|
||
config = _json.loads(config_file.read_text())
|
||
assert (
|
||
tmp_path / ".kilo" / "plugins" / "graphify.js"
|
||
).resolve().as_uri() in config.get("plugin", [])
|
||
|
||
|
||
def test_kilo_agents_install_merges_existing_config(tmp_path):
|
||
import json as _json
|
||
|
||
config_file = tmp_path / ".kilo" / "kilo.json"
|
||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||
config_file.write_text(
|
||
_json.dumps({"model": "anthropic/claude-sonnet", "plugin": []})
|
||
)
|
||
_agents_install(tmp_path, "kilo")
|
||
config = _json.loads(config_file.read_text())
|
||
assert config["model"] == "anthropic/claude-sonnet"
|
||
assert (
|
||
tmp_path / ".kilo" / "plugins" / "graphify.js"
|
||
).resolve().as_uri() in config["plugin"]
|
||
|
||
|
||
def test_kilo_agents_install_preserves_existing_jsonc_config(tmp_path):
|
||
import json as _json
|
||
|
||
config_file = tmp_path / ".kilo" / "kilo.jsonc"
|
||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||
original = """// user comment\n{\n // preferred model\n \"model\": \"anthropic/claude-haiku\",\n \"plugin\": []\n}\n"""
|
||
config_file.write_text(original)
|
||
_agents_install(tmp_path, "kilo")
|
||
json_file = tmp_path / ".kilo" / "kilo.json"
|
||
config = _json.loads(json_file.read_text())
|
||
assert config["model"] == "anthropic/claude-haiku"
|
||
assert (
|
||
tmp_path / ".kilo" / "plugins" / "graphify.js"
|
||
).resolve().as_uri() in config["plugin"]
|
||
assert config_file.read_text() == original
|
||
|
||
|
||
def test_kilo_agents_uninstall_preserves_existing_jsonc_config(tmp_path):
|
||
import json as _json
|
||
|
||
config_file = tmp_path / ".kilo" / "kilo.jsonc"
|
||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||
original = """// user comment\n{\n \"model\": \"anthropic/claude-haiku\",\n \"plugin\": []\n}\n"""
|
||
config_file.write_text(original)
|
||
|
||
_agents_install(tmp_path, "kilo")
|
||
_agents_uninstall(tmp_path, platform="kilo")
|
||
|
||
json_file = tmp_path / ".kilo" / "kilo.json"
|
||
config = _json.loads(json_file.read_text())
|
||
assert config_file.read_text() == original
|
||
assert (
|
||
tmp_path / ".kilo" / "plugins" / "graphify.js"
|
||
).resolve().as_uri() not in config.get("plugin", [])
|
||
|
||
|
||
def test_kilo_agents_install_idempotent(tmp_path):
|
||
import json as _json
|
||
|
||
_agents_install(tmp_path, "kilo")
|
||
_agents_install(tmp_path, "kilo")
|
||
content = (tmp_path / "AGENTS.md").read_text()
|
||
config = _json.loads((tmp_path / ".kilo" / "kilo.json").read_text())
|
||
plugin_uri = (tmp_path / ".kilo" / "plugins" / "graphify.js").resolve().as_uri()
|
||
assert content.count("## graphify") == 1
|
||
assert config["plugin"].count(plugin_uri) == 1
|
||
|
||
|
||
def test_kilo_install_writes_global_and_project_artifacts(tmp_path):
|
||
home_dir = tmp_path / "home"
|
||
project_dir = tmp_path / "project"
|
||
project_dir.mkdir()
|
||
home_dir.mkdir()
|
||
_kilo_install(project_dir, home_dir)
|
||
assert (home_dir / ".config" / "kilo" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert (home_dir / ".config" / "kilo" / "command" / "graphify.md").exists()
|
||
assert (project_dir / "AGENTS.md").exists()
|
||
assert (project_dir / ".kilo" / "plugins" / "graphify.js").exists()
|
||
|
||
|
||
def test_kilo_uninstall_removes_plugin_registration_and_command(tmp_path):
|
||
import json as _json
|
||
|
||
home_dir = tmp_path / "home"
|
||
project_dir = tmp_path / "project"
|
||
project_dir.mkdir()
|
||
home_dir.mkdir()
|
||
_kilo_install(project_dir, home_dir)
|
||
_kilo_uninstall(project_dir, home_dir)
|
||
assert not (home_dir / ".config" / "kilo" / "command" / "graphify.md").exists()
|
||
assert not (
|
||
home_dir / ".config" / "kilo" / "skills" / "graphify" / "SKILL.md"
|
||
).exists()
|
||
assert not (project_dir / ".kilo" / "plugins" / "graphify.js").exists()
|
||
config_file = project_dir / ".kilo" / "kilo.json"
|
||
if config_file.exists():
|
||
config = _json.loads(config_file.read_text())
|
||
assert (
|
||
project_dir / ".kilo" / "plugins" / "graphify.js"
|
||
).resolve().as_uri() not in config.get("plugin", [])
|
||
|
||
|
||
# ── Cursor ────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_cursor_install_writes_rule(tmp_path):
|
||
"""cursor install writes .cursor/rules/graphify.mdc."""
|
||
from graphify.__main__ import _cursor_install
|
||
|
||
_cursor_install(tmp_path)
|
||
rule = tmp_path / ".cursor" / "rules" / "graphify.mdc"
|
||
assert rule.exists()
|
||
content = rule.read_text()
|
||
assert "alwaysApply: true" in content
|
||
assert "graphify-out/GRAPH_REPORT.md" in content
|
||
|
||
|
||
def test_cursor_install_idempotent(tmp_path):
|
||
"""cursor install does not overwrite an existing rule file."""
|
||
from graphify.__main__ import _cursor_install
|
||
|
||
_cursor_install(tmp_path)
|
||
rule = tmp_path / ".cursor" / "rules" / "graphify.mdc"
|
||
original = rule.read_text()
|
||
_cursor_install(tmp_path)
|
||
assert rule.read_text() == original
|
||
|
||
|
||
def test_cursor_uninstall_removes_rule(tmp_path):
|
||
"""cursor uninstall removes the rule file."""
|
||
from graphify.__main__ import _cursor_install, _cursor_uninstall
|
||
|
||
_cursor_install(tmp_path)
|
||
_cursor_uninstall(tmp_path)
|
||
rule = tmp_path / ".cursor" / "rules" / "graphify.mdc"
|
||
assert not rule.exists()
|
||
|
||
|
||
def test_cursor_uninstall_noop_if_not_installed(tmp_path):
|
||
"""cursor uninstall does nothing if rule was never written."""
|
||
from graphify.__main__ import _cursor_uninstall
|
||
|
||
_cursor_uninstall(tmp_path) # should not raise
|
||
|
||
|
||
# ── Gemini CLI ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_gemini_install_writes_gemini_md(tmp_path):
|
||
from graphify.__main__ import gemini_install
|
||
|
||
gemini_install(tmp_path)
|
||
md = tmp_path / "GEMINI.md"
|
||
assert md.exists()
|
||
assert "graphify-out/GRAPH_REPORT.md" in md.read_text()
|
||
|
||
|
||
def test_gemini_install_writes_hook(tmp_path):
|
||
import json as _json
|
||
from graphify.__main__ import gemini_install
|
||
|
||
gemini_install(tmp_path)
|
||
settings = _json.loads((tmp_path / ".gemini" / "settings.json").read_text())
|
||
hooks = settings["hooks"]["BeforeTool"]
|
||
assert any("graphify" in str(h) for h in hooks)
|
||
|
||
|
||
def test_gemini_install_idempotent(tmp_path):
|
||
from graphify.__main__ import gemini_install
|
||
|
||
gemini_install(tmp_path)
|
||
gemini_install(tmp_path)
|
||
md = tmp_path / "GEMINI.md"
|
||
assert md.read_text().count("## graphify") == 1
|
||
|
||
|
||
def test_gemini_install_merges_existing_gemini_md(tmp_path):
|
||
from graphify.__main__ import gemini_install
|
||
|
||
(tmp_path / "GEMINI.md").write_text("# My project rules\n")
|
||
gemini_install(tmp_path)
|
||
content = (tmp_path / "GEMINI.md").read_text()
|
||
assert "# My project rules" in content
|
||
assert "graphify-out/GRAPH_REPORT.md" in content
|
||
|
||
|
||
def test_gemini_uninstall_removes_section(tmp_path):
|
||
from graphify.__main__ import gemini_install, gemini_uninstall
|
||
|
||
gemini_install(tmp_path)
|
||
gemini_uninstall(tmp_path)
|
||
md = tmp_path / "GEMINI.md"
|
||
assert not md.exists()
|
||
|
||
|
||
def test_gemini_uninstall_removes_hook(tmp_path):
|
||
import json as _json
|
||
from graphify.__main__ import gemini_install, gemini_uninstall
|
||
|
||
gemini_install(tmp_path)
|
||
gemini_uninstall(tmp_path)
|
||
settings_path = tmp_path / ".gemini" / "settings.json"
|
||
if settings_path.exists():
|
||
settings = _json.loads(settings_path.read_text())
|
||
hooks = settings.get("hooks", {}).get("BeforeTool", [])
|
||
assert not any("graphify" in str(h) for h in hooks)
|
||
|
||
|
||
def test_gemini_uninstall_noop_if_not_installed(tmp_path):
|
||
from graphify.__main__ import gemini_uninstall
|
||
|
||
gemini_uninstall(tmp_path) # should not raise
|
||
|
||
|
||
def test_amp_user_install_lands_in_config_agents(tmp_path, monkeypatch):
|
||
"""`graphify amp install` (user scope) must drop the skill into an Amp search
|
||
root: ~/.config/agents/skills, not the old ~/.amp/skills."""
|
||
from graphify.__main__ import main
|
||
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "install"])
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
main()
|
||
|
||
correct = home / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
|
||
old = home / ".amp" / "skills" / "graphify" / "SKILL.md"
|
||
assert correct.exists(), f"amp skill missing from Amp search root {correct}"
|
||
assert not old.exists(), f"amp skill must not land at the unsearched {old}"
|
||
# AGENTS.md still written in the project for the always-on rules.
|
||
assert (project / "AGENTS.md").exists()
|
||
|
||
|
||
def test_amp_install_cleans_legacy_amp_skills_dir(tmp_path, monkeypatch):
|
||
"""A pre-fix ~/.amp/skills/graphify install is removed on the next install."""
|
||
from graphify.__main__ import main
|
||
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
legacy = home / ".amp" / "skills" / "graphify"
|
||
legacy.mkdir(parents=True)
|
||
(legacy / "SKILL.md").write_text("old amp skill", encoding="utf-8")
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "install"])
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
main()
|
||
|
||
assert not legacy.exists(), "legacy ~/.amp/skills/graphify should be cleaned up"
|
||
assert (home / ".config" / "agents" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_amp_user_uninstall_removes_skill_and_agents(tmp_path, monkeypatch):
|
||
"""`graphify amp uninstall` removes the user-scope skill and AGENTS.md section."""
|
||
from graphify.__main__ import main
|
||
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "install"])
|
||
main()
|
||
skill = home / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
|
||
assert skill.exists()
|
||
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "uninstall"])
|
||
main()
|
||
|
||
assert not skill.exists()
|
||
assert not (home / ".config" / "agents" / "skills").exists()
|
||
assert not (project / "AGENTS.md").exists()
|
||
|
||
|
||
def test_amp_project_install_lands_in_dot_agents(tmp_path, monkeypatch):
|
||
"""Project-scope amp install lands in .agents/skills, an Amp project search root."""
|
||
from graphify.__main__ import main
|
||
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "install", "--project"])
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
main()
|
||
|
||
assert (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert not (project / ".amp" / "skills" / "graphify" / "SKILL.md").exists()
|
||
assert (project / "AGENTS.md").exists()
|
||
# User scope untouched.
|
||
assert not (home / ".config" / "agents" / "skills" / "graphify" / "SKILL.md").exists()
|
||
|
||
|
||
def test_uninstall_all_removes_amp_user_skill(tmp_path, monkeypatch):
|
||
"""The user-scope `graphify uninstall` enumeration removes the amp skill."""
|
||
from graphify.__main__ import main
|
||
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "amp", "install"])
|
||
main()
|
||
skill = home / ".config" / "agents" / "skills" / "graphify" / "SKILL.md"
|
||
assert skill.exists()
|
||
|
||
monkeypatch.setattr(sys, "argv", ["graphify", "uninstall"])
|
||
main()
|
||
|
||
assert not skill.exists()
|
||
|
||
|
||
def test_hermes_skill_destination_windows_uses_localappdata():
|
||
"""#1403: on Windows, Hermes scans %LOCALAPPDATA%\\hermes\\skills, so the global
|
||
skill must land there — not ~/.hermes/skills (the POSIX path)."""
|
||
from graphify.__main__ import _platform_skill_destination
|
||
with patch("graphify.__main__.platform.system", return_value="Windows"), \
|
||
patch.dict(os.environ, {"LOCALAPPDATA": str(Path("/tmp/AppDataLocal"))}):
|
||
dst = _platform_skill_destination("hermes", project=False)
|
||
assert dst == Path("/tmp/AppDataLocal") / "hermes" / "skills" / "graphify" / "SKILL.md", dst
|
||
|
||
|
||
def test_hermes_skill_destination_posix_uses_home():
|
||
"""Non-Windows hermes destination is unchanged (~/.hermes/skills)."""
|
||
from graphify.__main__ import _platform_skill_destination
|
||
with patch("graphify.__main__.platform.system", return_value="Linux"):
|
||
dst = _platform_skill_destination("hermes", project=False)
|
||
assert str(dst).endswith(".hermes/skills/graphify/SKILL.md"), dst
|
||
|
||
|
||
def _cli_dispatched_commands() -> set[str]:
|
||
"""Subcommand names the CLI actually dispatches.
|
||
|
||
`graphify`'s dispatcher is an `elif cmd == "..."` chain rather than a declarative
|
||
table, so the set is read back out of the source. Used to prove a hook command
|
||
written by an installer is not a stale/renamed subcommand (#2165).
|
||
"""
|
||
import re
|
||
from graphify import cli
|
||
|
||
source = Path(cli.__file__).read_text(encoding="utf-8")
|
||
names = set(re.findall(r'cmd\s*==\s*"([a-z0-9][a-z0-9-]*)"', source))
|
||
names |= {
|
||
m
|
||
for group in re.findall(r'cmd\s+in\s+\(([^)]*)\)', source)
|
||
for m in re.findall(r'"([a-z0-9][a-z0-9-]*)"', group)
|
||
}
|
||
return names
|
||
|
||
|
||
def test_codex_hook_command_is_a_real_cli_subcommand(tmp_path):
|
||
"""#2165: the PreToolUse command in .codex/hooks.json must be a command the CLI
|
||
dispatches, so a renamed subcommand can never leave a permanently dead hook.
|
||
|
||
`hook-check` is intentionally a no-op on Codex (Codex Desktop rejects
|
||
additionalContext on PreToolUse), but it must still be a *recognized* command --
|
||
an unrecognized one exits non-zero and would break every Bash tool call.
|
||
"""
|
||
import json
|
||
|
||
from graphify.install import _install_codex_hook
|
||
|
||
_install_codex_hook(tmp_path)
|
||
hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8"))
|
||
|
||
entries = [
|
||
h
|
||
for group in hooks["hooks"]["PreToolUse"]
|
||
for h in group["hooks"]
|
||
if "graphify" in h.get("command", "")
|
||
]
|
||
assert entries, "codex install must register a graphify PreToolUse hook"
|
||
|
||
dispatched = _cli_dispatched_commands()
|
||
assert "hook-check" in dispatched, "sanity: parser must find known commands"
|
||
|
||
for entry in entries:
|
||
# command is "<abs exe path> <subcommand> [args...]"
|
||
parts = entry["command"].split()
|
||
subcommand = parts[1] if len(parts) > 1 else ""
|
||
assert subcommand in dispatched, (
|
||
f"codex hook registers {subcommand!r}, which the CLI does not dispatch "
|
||
f"(#2165). Known commands: {sorted(dispatched)}"
|
||
)
|
||
|
||
|
||
# --- #3129: project-scoped installs must not embed a machine-absolute path ----
|
||
#
|
||
# `--project` writes hook config that the installer then tells the user to
|
||
# commit ("Add to version control: git add ..."). An exe path resolved from the
|
||
# installing machine is wrong in every other clone, and the drive letter and
|
||
# .EXE casing do not even survive between two Windows checkouts. The committed
|
||
# hook must name `graphify` and let PATH resolve it, the way the git-hook layer
|
||
# already does with `command -v graphify` (hooks.py). The user-profile install
|
||
# is deliberately left alone: it stays on the machine that wrote it, and an
|
||
# absolute path is what makes the hook work where the venv Scripts/ dir is not
|
||
# on PATH (e.g. the VS Code Codex extension on Windows).
|
||
|
||
_PROJECT_HOOK_FILES = {
|
||
"claude": ".claude/settings.json",
|
||
"codex": ".codex/hooks.json",
|
||
"gemini": ".gemini/settings.json",
|
||
}
|
||
|
||
|
||
def _hook_commands(text: str) -> list:
|
||
"""Every hook command string in a settings/hooks JSON document."""
|
||
import json as _json
|
||
|
||
doc = _json.loads(text)
|
||
found = []
|
||
for groups in doc.get("hooks", {}).values():
|
||
for group in groups:
|
||
for hook in group.get("hooks", []):
|
||
if "command" in hook:
|
||
found.append(hook["command"])
|
||
return found
|
||
|
||
|
||
def _run_project_install(project, home, platform):
|
||
from graphify.__main__ import main
|
||
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
with patch("sys.argv", ["graphify", "install", "--project", "--platform", platform]):
|
||
main()
|
||
|
||
|
||
@pytest.mark.parametrize("platform", sorted(_PROJECT_HOOK_FILES))
|
||
def test_project_install_hook_command_is_portable(tmp_path, monkeypatch, platform):
|
||
"""A committed hook must carry no absolute path, drive letter or .EXE casing."""
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
# Resolution would otherwise find a real graphify on this machine; pin it so
|
||
# the assertion fails loudly if the project path ever resolves again.
|
||
monkeypatch.setattr("shutil.which", lambda _name: r"C:\Users\installer\graphify.EXE")
|
||
|
||
_run_project_install(project, home, platform)
|
||
|
||
commands = _hook_commands((project / _PROJECT_HOOK_FILES[platform]).read_text(encoding="utf-8"))
|
||
assert commands, f"{platform} project install registered no hook command"
|
||
for command in commands:
|
||
assert command.startswith("graphify "), command
|
||
assert ":" not in command, f"drive letter / absolute path leaked: {command}"
|
||
assert "\\" not in command, f"backslash path leaked: {command}"
|
||
assert ".exe" not in command.lower(), f"platform exe casing leaked: {command}"
|
||
assert "installer" not in command, f"installing user's path leaked: {command}"
|
||
|
||
|
||
@pytest.mark.parametrize("platform", sorted(_PROJECT_HOOK_FILES))
|
||
def test_user_profile_install_still_resolves_absolute_path(tmp_path, monkeypatch, platform):
|
||
"""The non-project install keeps the resolved path (#522/#1987 behaviour)."""
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr("shutil.which", lambda _name: r"C:\Users\installer\graphify.EXE")
|
||
|
||
from graphify.__main__ import main
|
||
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
with patch("sys.argv", ["graphify", platform, "install"]):
|
||
main()
|
||
|
||
commands = _hook_commands((project / _PROJECT_HOOK_FILES[platform]).read_text(encoding="utf-8"))
|
||
assert commands, f"{platform} install registered no hook command"
|
||
for command in commands:
|
||
assert command.startswith("C:/Users/installer/graphify.EXE "), command
|
||
|
||
|
||
@pytest.mark.parametrize("platform", sorted(_PROJECT_HOOK_FILES))
|
||
def test_project_install_is_idempotent(tmp_path, monkeypatch, platform):
|
||
"""Installing twice leaves byte-identical config (no churn on re-install)."""
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr("shutil.which", lambda _name: r"C:\Users\installer\graphify.EXE")
|
||
target = project / _PROJECT_HOOK_FILES[platform]
|
||
|
||
_run_project_install(project, home, platform)
|
||
first = target.read_text(encoding="utf-8")
|
||
_run_project_install(project, home, platform)
|
||
|
||
assert target.read_text(encoding="utf-8") == first
|
||
|
||
|
||
def test_project_uninstall_removes_the_bare_hook_command(tmp_path, monkeypatch):
|
||
"""The uninstall filter matches on "graphify", so a bare command still goes."""
|
||
from graphify.__main__ import main
|
||
|
||
home = tmp_path / "home"
|
||
project = tmp_path / "project"
|
||
project.mkdir()
|
||
monkeypatch.chdir(project)
|
||
monkeypatch.setattr("shutil.which", lambda _name: r"C:\Users\installer\graphify.EXE")
|
||
|
||
_run_project_install(project, home, "claude")
|
||
settings = project / ".claude" / "settings.json"
|
||
assert any("hook-guard" in c for c in _hook_commands(settings.read_text(encoding="utf-8")))
|
||
|
||
with patch("graphify.__main__.Path.home", return_value=home):
|
||
with patch("sys.argv", ["graphify", "claude", "uninstall", "--project"]):
|
||
main()
|
||
|
||
assert not [c for c in _hook_commands(settings.read_text(encoding="utf-8")) if "graphify" in c]
|