Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 29 additions & 32 deletions graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,12 @@ def _refresh_all_version_stamps() -> None:
vf.write_text(__version__, encoding="utf-8")
def _platform_skill_destination(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
"""Return the skill destination for a platform and scope."""
if platform_name == "gemini":
if project:
return (project_dir or Path(".")) / ".gemini" / "skills" / "graphify" / "SKILL.md"
if platform.system() == "Windows":
return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
return Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
if platform_name in ("gemini", "antigravity", "antigravity-windows"):
# Since Antigravity 2.0, the Antigravity IDE, the Antigravity CLI and the
# Gemini CLI all read global skills from ~/.gemini/config/skills/ (every
# workspace, every OS) — the old ~/.gemini/skills path is not scanned.
# Routing gemini here (not ~/.gemini/skills) is the #1185 fix.
return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"

if platform_name == "opencode":
if project:
Expand Down Expand Up @@ -105,12 +105,6 @@ def _platform_skill_destination(platform_name: str, *, project: bool = False, pr
return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
return Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"

if platform_name in ("antigravity", "antigravity-windows"):
if project:
return (project_dir or Path(".")) / ".agents" / "skills" / "graphify" / "SKILL.md"
# Global Antigravity skill dir (all workspaces): ~/.gemini/config/skills/
return Path.home() / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"

cfg = _PLATFORM_CONFIG[platform_name]
if project:
return (project_dir or Path(".")) / cfg["skill_dst"]
Expand Down Expand Up @@ -947,26 +941,29 @@ def _antigravity_uninstall(project_dir: Path, *, project: bool = False) -> None:
wf_path.unlink()
print(f"graphify workflow removed from {wf_path.resolve()}")

# Remove skill file
skill_dst = _platform_skill_destination("antigravity", project=project, project_dir=project_dir)
if skill_dst.exists():
skill_dst.unlink()
print(f"graphify skill removed from {skill_dst}")
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
refs_dir = skill_dst.parent / "references"
if refs_dir.exists():
shutil.rmtree(refs_dir)
for d in (
skill_dst.parent,
skill_dst.parent.parent,
skill_dst.parent.parent.parent,
):
try:
d.rmdir()
except OSError:
break
# Remove skill file only for global uninstall. The skill lives in the shared
# global ~/.gemini/config/skills/ dir, so a project-scope uninstall must not
# delete it out from under other workspaces (#1185).
if not project:
skill_dst = _platform_skill_destination("antigravity", project=project, project_dir=project_dir)
if skill_dst.exists():
skill_dst.unlink()
print(f"graphify skill removed from {skill_dst}")
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
refs_dir = skill_dst.parent / "references"
if refs_dir.exists():
shutil.rmtree(refs_dir)
for d in (
skill_dst.parent,
skill_dst.parent.parent,
skill_dst.parent.parent.parent,
):
try:
d.rmdir()
except OSError:
break
_CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc"
_CURSOR_RULE = """\
---
Expand Down
36 changes: 26 additions & 10 deletions tests/test_antigravity_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,37 @@
import graphify.__main__ as m


from unittest.mock import patch

def test_antigravity_project_install_writes_rules_and_workflows(tmp_path):
m._project_install("antigravity", tmp_path)
skill = tmp_path / ".agents" / "skills" / "graphify" / "SKILL.md"
rules = tmp_path / ".agents" / "rules" / "graphify.md"
workflow = tmp_path / ".agents" / "workflows" / "graphify.md"
assert skill.exists(), "skill should be installed under .agents/skills/"
home = tmp_path / "home"
proj = tmp_path / "proj"
home.mkdir()
proj.mkdir()
with patch("graphify.__main__.Path.home", return_value=home):
m._project_install("antigravity", proj)

skill = home / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
rules = proj / ".agents" / "rules" / "graphify.md"
workflow = proj / ".agents" / "workflows" / "graphify.md"

assert skill.exists(), "skill should be installed globally under ~/.gemini/config/skills/"
assert rules.exists(), "antigravity rules (always-on) must be written"
assert workflow.exists(), "antigravity workflow must be written"
# native tool-discovery frontmatter is injected into the skill
assert skill.read_text(encoding="utf-8").startswith("---\n")


def test_antigravity_project_uninstall_clears_rules_and_workflows(tmp_path):
m._project_install("antigravity", tmp_path)
m._project_uninstall("antigravity", tmp_path)
assert not (tmp_path / ".agents" / "rules" / "graphify.md").exists()
assert not (tmp_path / ".agents" / "workflows" / "graphify.md").exists()
assert not (tmp_path / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
home = tmp_path / "home"
proj = tmp_path / "proj"
home.mkdir()
proj.mkdir()
with patch("graphify.__main__.Path.home", return_value=home):
m._project_install("antigravity", proj)
m._project_uninstall("antigravity", proj)

assert not (proj / ".agents" / "rules" / "graphify.md").exists()
assert not (proj / ".agents" / "workflows" / "graphify.md").exists()
# The global skill should remain intact after a project uninstall.
assert (home / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md").exists()
14 changes: 6 additions & 8 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def test_codex_subcommand_project_install_and_uninstall_are_project_scoped(tmp_p
assert "graphify" not in hooks_path.read_text()


def test_antigravity_install_project_writes_project_skill(tmp_path, monkeypatch):
def test_antigravity_install_project_writes_global_skill(tmp_path, monkeypatch):
from graphify.__main__ import main
home = tmp_path / "home"
project = tmp_path / "project"
Expand All @@ -162,8 +162,8 @@ def test_antigravity_install_project_writes_project_skill(tmp_path, monkeypatch)
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()
assert (home / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md").exists()
assert not (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()


def test_install_help_does_not_install_default(tmp_path, monkeypatch, capsys):
Expand Down Expand Up @@ -225,7 +225,7 @@ def test_codex_skill_uses_graphify_with_existing_graph():
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()
skill = (Path(graphify.__file__).parent / "skill-codex.md").read_text(encoding="utf-8")
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
Expand Down Expand Up @@ -474,12 +474,11 @@ def test_uninstall_project_without_platform_removes_project_installs(tmp_path, m
assert not (project / ".claude" / "CLAUDE.md").exists()


def test_antigravity_uninstall_project_removes_project_skill_only(tmp_path, monkeypatch):
def test_antigravity_uninstall_project_preserves_global_skill(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")
Expand All @@ -490,7 +489,6 @@ def test_antigravity_uninstall_project_removes_project_skill_only(tmp_path, monk
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):
Expand Down Expand Up @@ -1056,4 +1054,4 @@ def test_hermes_skill_destination_posix_uses_home():
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
assert dst.as_posix().endswith(".hermes/skills/graphify/SKILL.md"), dst
2 changes: 1 addition & 1 deletion tests/test_install_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ def test_gemini_install_references_all_resolve(tmp_path):
"""
import re
_install(tmp_path, "gemini")
skill = tmp_path / ".gemini" / "skills" / "graphify" / "SKILL.md"
skill = tmp_path / ".gemini" / "config" / "skills" / "graphify" / "SKILL.md"
assert skill.exists()
refdir = skill.parent / "references"
assert refdir.is_dir()
Expand Down
67 changes: 34 additions & 33 deletions tests/test_install_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,39 +59,40 @@ def test_skill_roundtrip_at_real_destination(platform, project, tmp_path, monkey
monkeypatch.chdir(project_dir)

with patch("graphify.__main__.Path.home", return_value=home):
dst = mainmod._platform_skill_destination(
platform, project=project, project_dir=project_dir
)
# Sanity: a user-scope install must not write under the project dir, and
# vice versa, so the two scopes never collide in this test.
if project:
assert str(dst).startswith(str(project_dir))
else:
assert str(dst).startswith(str(home))

returned = mainmod._copy_skill_file(
platform, project=project, project_dir=project_dir
)
assert returned == dst
assert dst.exists(), f"{platform} ({'project' if project else 'user'}) skill not installed"
assert (dst.parent / ".graphify_version").read_text() == mainmod.__version__

refs = dst.parent / "references"
if _has_real_bundle(platform):
assert refs.is_dir(), f"{platform} ships a bundle but no references/ installed"
assert (refs / "extraction-spec.md").exists()
else:
assert not refs.exists(), f"{platform} is monolith but references/ appeared"
# No staging dir is ever left behind.
assert not (dst.parent / "references.tmp").exists()

removed = mainmod._remove_skill_file(
platform, project=project, project_dir=project_dir
)
assert removed
assert not dst.exists()
assert not (dst.parent / ".graphify_version").exists()
assert not refs.exists()
with patch.dict("os.environ", {"LOCALAPPDATA": str(home / "AppData" / "Local")}):
dst = mainmod._platform_skill_destination(
platform, project=project, project_dir=project_dir
)
# Sanity: a user-scope install must not write under the project dir, and
# vice versa, so the two scopes never collide in this test.
if project and platform not in ("antigravity", "antigravity-windows", "gemini"):
assert str(dst).startswith(str(project_dir))
else:
assert str(dst).startswith(str(home))

returned = mainmod._copy_skill_file(
platform, project=project, project_dir=project_dir
)
assert returned == dst
assert dst.exists(), f"{platform} ({'project' if project else 'user'}) skill not installed"
assert (dst.parent / ".graphify_version").read_text() == mainmod.__version__

refs = dst.parent / "references"
if _has_real_bundle(platform):
assert refs.is_dir(), f"{platform} ships a bundle but no references/ installed"
assert (refs / "extraction-spec.md").exists()
else:
assert not refs.exists(), f"{platform} is monolith but references/ appeared"
# No staging dir is ever left behind.
assert not (dst.parent / "references.tmp").exists()

removed = mainmod._remove_skill_file(
platform, project=project, project_dir=project_dir
)
assert removed
assert not dst.exists()
assert not (dst.parent / ".graphify_version").exists()
assert not refs.exists()


def test_amp_user_install_at_corrected_agents_path(tmp_path, monkeypatch):
Expand Down
Loading