"""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_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", "Read|Glob"} <= matchers 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 # --- 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 "" && ` 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