feat(install): add project-scoped skill installs (#931)

* feat(install): add project-scoped skill installs

* fix(install): cover project-scoped antigravity install

* test(install): cover project-scoped platform subcommands

---------

Co-authored-by: hanmo1 <hanmo1@lenovo.com>
This commit is contained in:
balloon72
2026-05-22 21:38:35 +08:00
committed by GitHub
parent 3238b32677
commit b3474924c2
3 changed files with 465 additions and 116 deletions
+15
View File
@@ -98,6 +98,20 @@ graphify install
That's it. Open your AI assistant and type `/graphify .`
To install the assistant skill into the current repository instead of your user
profile, add `--project`:
```bash
graphify install --project
graphify install --project --platform codex
```
Project-scoped installs write under the current directory, for example
`.claude/skills/graphify/SKILL.md` or `.agents/skills/graphify/SKILL.md`, and
print a `git add` hint for files that can be committed.
Per-platform commands that support project-scoped installs accept the same flag,
for example `graphify claude install --project` or `graphify codex install --project`.
> **PowerShell note:** Use `graphify .` not `/graphify .` — the leading slash is a path separator in PowerShell.
> **`graphify: command not found`?** Use `uv tool install graphifyy` or `pipx install graphifyy` — both put the CLI on PATH automatically. With plain `pip`, add `~/.local/bin` (Linux) or `~/Library/Python/3.x/bin` (Mac) to your PATH, or run `python -m graphify`.
@@ -433,6 +447,7 @@ graphify install # overwrites the skill file
graphify uninstall # remove from all platforms in one shot
graphify uninstall --purge # also delete graphify-out/
graphify uninstall --project --platform codex # remove project-scoped install files only
graphify hook install # post-commit + post-checkout hooks
graphify hook uninstall
+295 -116
View File
@@ -67,6 +67,110 @@ def _refresh_all_version_stamps() -> None:
if skill_dst.exists():
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"
cfg = _PLATFORM_CONFIG[platform_name]
if project:
return (project_dir or Path(".")) / cfg["skill_dst"]
if platform_name in ("claude", "windows") and os.environ.get("CLAUDE_CONFIG_DIR"):
return Path(os.environ["CLAUDE_CONFIG_DIR"]) / "skills" / "graphify" / "SKILL.md"
return Path.home() / cfg["skill_dst"]
def _copy_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> Path:
"""Copy a packaged skill file and write its version stamp."""
skill_file = "skill.md" if platform_name == "gemini" else _PLATFORM_CONFIG[platform_name]["skill_file"]
skill_src = Path(__file__).parent / skill_file
if not skill_src.exists():
print(f"error: {skill_file} not found in package - reinstall graphify", file=sys.stderr)
sys.exit(1)
skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
skill_dst.parent.mkdir(parents=True, exist_ok=True)
tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
try:
shutil.copy(skill_src, tmp_dst)
os.replace(tmp_dst, skill_dst)
except Exception:
try:
tmp_dst.unlink(missing_ok=True)
except OSError:
pass
raise
(skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
print(f" skill installed -> {skill_dst}")
return skill_dst
def _remove_skill_file(platform_name: str, *, project: bool = False, project_dir: Path | None = None) -> bool:
"""Remove a platform skill file and its version stamp without touching other scopes."""
skill_dst = _platform_skill_destination(platform_name, project=project, project_dir=project_dir)
removed = False
if skill_dst.exists():
skill_dst.unlink()
print(f" skill removed -> {skill_dst}")
removed = True
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
removed = True
for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
try:
d.rmdir()
except OSError:
break
return removed
def _project_scope_root(path: Path, project_dir: Path) -> Path:
"""Return the top-level project artifact for a project-scoped skill path."""
try:
rel = path.relative_to(project_dir)
except ValueError:
return path
return project_dir / rel.parts[0] if rel.parts else path
def _remove_claude_skill_registration(project_dir: Path) -> None:
"""Remove the project-scoped Claude skill registration file/section."""
claude_md = project_dir / ".claude" / "CLAUDE.md"
if not claude_md.exists():
return
content = claude_md.read_text(encoding="utf-8")
if "# graphify" not in content:
return
cleaned = re.sub(r"\n*# graphify\n.*?(?=\n# |\Z)", "", content, flags=re.DOTALL).rstrip()
if cleaned:
claude_md.write_text(cleaned + "\n", encoding="utf-8")
print(f" CLAUDE.md -> graphify skill registration removed from {claude_md}")
else:
claude_md.unlink()
print(f" CLAUDE.md -> deleted {claude_md}")
def _print_project_git_add_hint(paths: list[Path]) -> None:
unique: list[str] = []
for path in paths:
text = path.as_posix().rstrip("/")
if path.exists() and path.is_dir():
text += "/"
if text not in unique:
unique.append(text)
if not unique:
return
print()
print("Project-scoped install. Add to version control:")
print(f" git add {' '.join(unique)}")
_SETTINGS_HOOK = {
# Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash.
# We match on Bash and inspect the command string to avoid firing on every shell call.
@@ -89,13 +193,14 @@ _SETTINGS_HOOK = {
],
}
_SKILL_REGISTRATION = (
"\n# graphify\n"
"- **graphify** (`~/.claude/skills/graphify/SKILL.md`) "
"- any input to knowledge graph. Trigger: `/graphify`\n"
"When the user types `/graphify`, invoke the Skill tool "
"with `skill: \"graphify\"` before doing anything else.\n"
)
def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str:
return (
"\n# graphify\n"
f"- **graphify** (`{skill_path}`) "
"- any input to knowledge graph. Trigger: `/graphify`\n"
"When the user types `/graphify`, invoke the Skill tool "
"with `skill: \"graphify\"` before doing anything else.\n"
)
_PLATFORM_CONFIG: dict[str, dict] = {
@@ -227,9 +332,9 @@ def _replace_or_append_section(content: str, marker: str, new_section: str) -> s
return out
def install(platform: str = "claude") -> None:
def install(platform: str = "claude", *, project: bool = False, project_dir: Path | None = None) -> None:
if platform == "gemini":
gemini_install()
gemini_install(project_dir=project_dir, project=project)
return
if platform == "cursor":
_cursor_install(Path("."))
@@ -245,52 +350,34 @@ def install(platform: str = "claude") -> None:
sys.exit(1)
cfg = _PLATFORM_CONFIG[platform]
skill_src = Path(__file__).parent / cfg["skill_file"]
if not skill_src.exists():
print(f"error: {cfg['skill_file']} not found in package - reinstall graphify", file=sys.stderr)
sys.exit(1)
import os as _os
if platform in ("claude", "windows") and _os.environ.get("CLAUDE_CONFIG_DIR"):
_claude_base = Path(_os.environ["CLAUDE_CONFIG_DIR"])
skill_dst = _claude_base / "skills" / "graphify" / "SKILL.md"
else:
skill_dst = Path.home() / cfg["skill_dst"]
skill_dst.parent.mkdir(parents=True, exist_ok=True)
tmp_dst = skill_dst.with_suffix(skill_dst.suffix + ".tmp")
try:
shutil.copy(skill_src, tmp_dst)
os.replace(tmp_dst, skill_dst)
except Exception:
try:
tmp_dst.unlink(missing_ok=True)
except OSError:
pass
raise
(skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
print(f" skill installed -> {skill_dst}")
project_dir = project_dir or Path(".")
skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir)
if cfg["claude_md"]:
# Register in ~/.claude/CLAUDE.md (Claude Code only)
claude_md = Path.home() / ".claude" / "CLAUDE.md"
# Register in the matching Claude Code scope.
claude_md = (project_dir / ".claude" / "CLAUDE.md") if project else Path.home() / ".claude" / "CLAUDE.md"
registration = _skill_registration(".claude/skills/graphify/SKILL.md" if project else "~/.claude/skills/graphify/SKILL.md")
if claude_md.exists():
content = claude_md.read_text(encoding="utf-8")
if "graphify" in content:
print(f" CLAUDE.md -> already registered (no change)")
else:
claude_md.write_text(content.rstrip() + _SKILL_REGISTRATION, encoding="utf-8")
claude_md.write_text(content.rstrip() + registration, encoding="utf-8")
print(f" CLAUDE.md -> skill registered in {claude_md}")
else:
claude_md.parent.mkdir(parents=True, exist_ok=True)
claude_md.write_text(_SKILL_REGISTRATION.lstrip(), encoding="utf-8")
claude_md.write_text(registration.lstrip(), encoding="utf-8")
print(f" CLAUDE.md -> created at {claude_md}")
if platform == "opencode":
_install_opencode_plugin(Path("."))
_install_opencode_plugin(project_dir if project else Path("."))
# Refresh version stamps in all other previously-installed skill dirs so
# stale-version warnings don't fire for platforms not explicitly re-installed.
_refresh_all_version_stamps()
if project:
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
else:
_refresh_all_version_stamps()
print()
print("Done. Open your AI coding assistant and type:")
@@ -301,7 +388,7 @@ def install(platform: str = "claude") -> None:
def _print_install_usage() -> None:
platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"])
print("Usage: graphify install [--platform P|P]")
print("Usage: graphify install [--project] [--platform P|P]")
print(f"Platforms: {platforms}")
@@ -371,21 +458,12 @@ _GEMINI_HOOK = {
}
def gemini_install(project_dir: Path | None = None) -> None:
"""Copy skill file to ~/.gemini/skills/graphify/, write GEMINI.md section, and install BeforeTool hook."""
# Copy skill file to ~/.gemini/skills/graphify/SKILL.md
# On Windows, Gemini CLI prioritises ~/.agents/skills/ over ~/.gemini/skills/
skill_src = Path(__file__).parent / "skill.md"
if platform.system() == "Windows":
skill_dst = Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
else:
skill_dst = Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
skill_dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(skill_src, skill_dst)
(skill_dst.parent / ".graphify_version").write_text(__version__, encoding="utf-8")
print(f" skill installed -> {skill_dst}")
def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None:
"""Copy skill file, write GEMINI.md section, and install BeforeTool hook."""
project_dir = project_dir or Path(".")
skill_dst = _copy_skill_file("gemini", project=project, project_dir=project_dir)
target = (project_dir or Path(".")) / "GEMINI.md"
target = project_dir / "GEMINI.md"
if target.exists():
content = target.read_text(encoding="utf-8")
@@ -403,7 +481,9 @@ def gemini_install(project_dir: Path | None = None) -> None:
# Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580
# wording) is replaced on upgrade.
_install_gemini_hook(project_dir or Path("."))
_install_gemini_hook(project_dir)
if project:
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir), project_dir / "GEMINI.md", project_dir / ".gemini"])
print()
print("Gemini CLI will now check the knowledge graph before answering")
print("codebase questions and rebuild it after code changes.")
@@ -440,26 +520,12 @@ def _uninstall_gemini_hook(project_dir: Path) -> None:
print(" .gemini/settings.json -> BeforeTool hook removed")
def gemini_uninstall(project_dir: Path | None = None) -> None:
def gemini_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
"""Remove the graphify section from GEMINI.md, uninstall hook, and remove skill file."""
# Remove skill file (mirror the install path detection)
if platform.system() == "Windows":
skill_dst = Path.home() / ".agents" / "skills" / "graphify" / "SKILL.md"
else:
skill_dst = Path.home() / ".gemini" / "skills" / "graphify" / "SKILL.md"
if skill_dst.exists():
skill_dst.unlink()
print(f" skill removed -> {skill_dst}")
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
for d in (skill_dst.parent, skill_dst.parent.parent):
try:
d.rmdir()
except OSError:
break
project_dir = project_dir or Path(".")
_remove_skill_file("gemini", project=project, project_dir=project_dir)
target = (project_dir or Path(".")) / "GEMINI.md"
target = project_dir / "GEMINI.md"
if not target.exists():
print("No GEMINI.md found in current directory - nothing to do")
return
@@ -474,7 +540,7 @@ def gemini_uninstall(project_dir: Path | None = None) -> None:
else:
target.unlink()
print(f"GEMINI.md was empty after removal - deleted {target.resolve()}")
_uninstall_gemini_hook(project_dir or Path("."))
_uninstall_gemini_hook(project_dir)
_VSCODE_INSTRUCTIONS_MARKER = "## graphify"
@@ -990,6 +1056,76 @@ def _agents_install(project_dir: Path, platform: str) -> None:
print(f"{platform.capitalize()} — the AGENTS.md rules are the always-on mechanism.")
def _project_install(platform_name: str, project_dir: Path | None = None) -> None:
"""Install platform skill/config files in the current project."""
project_dir = project_dir or Path(".")
if platform_name in ("claude", "windows"):
install(platform=platform_name, project=True, project_dir=project_dir)
claude_install(project_dir)
_print_project_git_add_hint([project_dir / ".claude", project_dir / "CLAUDE.md"])
elif platform_name == "gemini":
gemini_install(project_dir, project=True)
elif platform_name == "cursor":
_cursor_install(project_dir)
_print_project_git_add_hint([project_dir / ".cursor"])
elif platform_name == "kiro":
_kiro_install(project_dir)
_print_project_git_add_hint([project_dir / ".kiro"])
elif platform_name in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
_agents_install(project_dir, platform_name)
hint_paths = [_project_scope_root(skill_dst, project_dir), project_dir / "AGENTS.md"]
if platform_name == "opencode":
hint_paths.append(project_dir / ".opencode")
elif platform_name == "codex":
hint_paths.append(project_dir / ".codex")
_print_project_git_add_hint(hint_paths)
elif platform_name in ("copilot", "pi", "antigravity", "kimi"):
skill_dst = _copy_skill_file(platform_name, project=True, project_dir=project_dir)
_print_project_git_add_hint([_project_scope_root(skill_dst, project_dir)])
else:
install(platform=platform_name, project=True, project_dir=project_dir)
def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> None:
"""Remove project-scoped platform skill/config files only."""
project_dir = project_dir or Path(".")
if platform_name in ("claude", "windows"):
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
_remove_claude_skill_registration(project_dir)
claude_uninstall(project_dir)
elif platform_name == "gemini":
gemini_uninstall(project_dir, project=True)
elif platform_name == "cursor":
_cursor_uninstall(project_dir)
elif platform_name == "kiro":
_kiro_uninstall(project_dir)
elif platform_name in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
_agents_uninstall(project_dir, platform=platform_name)
if platform_name == "codex":
_uninstall_codex_hook(project_dir)
elif platform_name == "antigravity":
_antigravity_uninstall(project_dir)
elif platform_name in ("copilot", "pi", "kimi"):
removed = _remove_skill_file(platform_name, project=True, project_dir=project_dir)
if not removed:
print("nothing to remove")
else:
_remove_skill_file(platform_name, project=True, project_dir=project_dir)
def _project_uninstall_all(project_dir: Path | None = None) -> None:
"""Remove project-scoped install files without touching user-scope installs."""
project_dir = project_dir or Path(".")
print("Uninstalling project-scoped graphify files...\n")
for platform_name in _PLATFORM_CONFIG:
_project_uninstall(platform_name, project_dir)
for platform_name in ("gemini", "cursor"):
_project_uninstall(platform_name, project_dir)
print("\nDone.")
def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
"""Remove the graphify section from the local AGENTS.md."""
target = (project_dir or Path(".")) / "AGENTS.md"
@@ -1361,6 +1497,7 @@ def main() -> None:
# Default to windows platform on Windows, claude elsewhere
default_platform = "windows" if platform.system() == "Windows" else "claude"
selected_platform: str | None = None
project_scope = False
args = sys.argv[2:]
i = 0
while i < len(args):
@@ -1368,7 +1505,10 @@ def main() -> None:
if arg in ("-h", "--help"):
_print_install_usage()
return
if arg.startswith("--platform="):
if arg == "--project":
project_scope = True
i += 1
elif arg.startswith("--platform="):
candidate = arg.split("=", 1)[1]
if selected_platform and selected_platform != candidate:
print("error: specify install platform only once", file=sys.stderr)
@@ -1395,25 +1535,63 @@ def main() -> None:
selected_platform = arg
i += 1
chosen_platform = selected_platform or default_platform
install(platform=chosen_platform)
if project_scope:
_project_install(chosen_platform, Path("."))
else:
install(platform=chosen_platform)
elif cmd == "uninstall":
purge = "--purge" in sys.argv[2:]
uninstall_all(purge=purge)
args = sys.argv[2:]
purge = "--purge" in args
project_scope = "--project" in args
selected_platform = None
i = 0
while i < len(args):
arg = args[i]
if arg in ("--purge", "--project"):
i += 1
elif arg.startswith("--platform="):
selected_platform = arg.split("=", 1)[1]
i += 1
elif arg == "--platform":
if i + 1 >= len(args):
print("error: --platform requires a value", file=sys.stderr)
sys.exit(1)
selected_platform = args[i + 1]
i += 2
elif arg.startswith("-"):
print(f"error: unknown uninstall option '{arg}'", file=sys.stderr)
sys.exit(1)
else:
selected_platform = arg
i += 1
if project_scope:
if selected_platform:
_project_uninstall(selected_platform, Path("."))
else:
_project_uninstall_all(Path("."))
else:
uninstall_all(purge=purge)
elif cmd == "claude":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
claude_install()
if "--project" in sys.argv[3:]:
_project_install("claude", Path("."))
else:
claude_install()
elif subcmd == "uninstall":
claude_uninstall()
if "--project" in sys.argv[3:]:
_project_uninstall("claude", Path("."))
else:
claude_uninstall()
else:
print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "gemini":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
gemini_install()
gemini_install(project=("--project" in sys.argv[3:]))
elif subcmd == "uninstall":
gemini_uninstall()
gemini_uninstall(project=("--project" in sys.argv[3:]))
else:
print("Usage: graphify gemini [install|uninstall]", file=sys.stderr)
sys.exit(1)
@@ -1438,22 +1616,16 @@ def main() -> None:
elif cmd == "copilot":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
install(platform="copilot")
if "--project" in sys.argv[3:]:
_project_install("copilot", Path("."))
else:
install(platform="copilot")
elif subcmd == "uninstall":
skill_dst = Path.home() / _PLATFORM_CONFIG["copilot"]["skill_dst"]
removed = []
if skill_dst.exists():
skill_dst.unlink()
removed.append(f"skill removed: {skill_dst}")
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
try:
d.rmdir()
except OSError:
break
print("; ".join(removed) if removed else "nothing to remove")
if "--project" in sys.argv[3:]:
_project_uninstall("copilot", Path("."))
else:
removed = _remove_skill_file("copilot")
print("skill removed" if removed else "nothing to remove")
else:
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
sys.exit(1)
@@ -1469,40 +1641,47 @@ def main() -> None:
elif cmd == "pi":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
install("pi")
if "--project" in sys.argv[3:]:
_project_install("pi", Path("."))
else:
install("pi")
elif subcmd == "uninstall":
skill_dst = Path.home() / ".pi" / "agent" / "skills" / "graphify" / "SKILL.md"
if skill_dst.exists():
skill_dst.unlink()
print(f" skill removed -> {skill_dst}")
version_file = skill_dst.parent / ".graphify_version"
if version_file.exists():
version_file.unlink()
for d in (skill_dst.parent, skill_dst.parent.parent, skill_dst.parent.parent.parent):
try:
d.rmdir()
except OSError:
break
if "--project" in sys.argv[3:]:
_project_uninstall("pi", Path("."))
else:
_remove_skill_file("pi")
else:
print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
_agents_install(Path("."), cmd)
if "--project" in sys.argv[3:]:
_project_install(cmd, Path("."))
else:
_agents_install(Path("."), cmd)
elif subcmd == "uninstall":
_agents_uninstall(Path("."), platform=cmd)
if cmd == "codex":
_uninstall_codex_hook(Path("."))
if "--project" in sys.argv[3:]:
_project_uninstall(cmd, Path("."))
else:
_agents_uninstall(Path("."), platform=cmd)
if cmd == "codex":
_uninstall_codex_hook(Path("."))
else:
print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "antigravity":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
_antigravity_install(Path("."))
if "--project" in sys.argv[3:]:
_project_install("antigravity", Path("."))
else:
_antigravity_install(Path("."))
elif subcmd == "uninstall":
_antigravity_uninstall(Path("."))
if "--project" in sys.argv[3:]:
_project_uninstall("antigravity", Path("."))
else:
_antigravity_uninstall(Path("."))
else:
print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr)
sys.exit(1)
+155
View File
@@ -54,6 +54,105 @@ def test_install_positional_platform_opencode(tmp_path, monkeypatch):
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 / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
assert (project / "AGENTS.md").exists()
assert (project / ".codex" / "hooks.json").exists()
assert not (home / ".agents" / "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 / ".agents" / "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 / ".agents" / "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 / ".agents" / "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)
@@ -171,6 +270,62 @@ def test_codex_install_does_not_write_claude_md(tmp_path):
assert not (tmp_path / ".claude" / "CLAUDE.md").exists()
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 / ".agents" / "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 / ".agents" / "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()
user_skill = home / ".agents" / "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", "antigravity", "install", "--project"])
main()
monkeypatch.setattr(sys, "argv", ["graphify", "antigravity", "uninstall", "--project"])
main()
assert user_skill.exists()
assert not (project / ".agents" / "skills" / "graphify" / "SKILL.md").exists()
# --- always-on AGENTS.md install/uninstall tests ---
def _agents_install(tmp_path, platform):