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 14:38:35 +01:00
committed by GitHub
co-authored by hanmo1
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 .` 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. > **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`. > **`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 # remove from all platforms in one shot
graphify uninstall --purge # also delete graphify-out/ 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 install # post-commit + post-checkout hooks
graphify hook uninstall graphify hook uninstall
+295 -116
View File
@@ -67,6 +67,110 @@ def _refresh_all_version_stamps() -> None:
if skill_dst.exists(): if skill_dst.exists():
vf.write_text(__version__, encoding="utf-8") 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 = { _SETTINGS_HOOK = {
# Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash. # 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. # We match on Bash and inspect the command string to avoid firing on every shell call.
@@ -89,13 +193,14 @@ _SETTINGS_HOOK = {
], ],
} }
_SKILL_REGISTRATION = ( def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str:
"\n# graphify\n" return (
"- **graphify** (`~/.claude/skills/graphify/SKILL.md`) " "\n# graphify\n"
"- any input to knowledge graph. Trigger: `/graphify`\n" f"- **graphify** (`{skill_path}`) "
"When the user types `/graphify`, invoke the Skill tool " "- any input to knowledge graph. Trigger: `/graphify`\n"
"with `skill: \"graphify\"` before doing anything else.\n" "When the user types `/graphify`, invoke the Skill tool "
) "with `skill: \"graphify\"` before doing anything else.\n"
)
_PLATFORM_CONFIG: dict[str, dict] = { _PLATFORM_CONFIG: dict[str, dict] = {
@@ -227,9 +332,9 @@ def _replace_or_append_section(content: str, marker: str, new_section: str) -> s
return out 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": if platform == "gemini":
gemini_install() gemini_install(project_dir=project_dir, project=project)
return return
if platform == "cursor": if platform == "cursor":
_cursor_install(Path(".")) _cursor_install(Path("."))
@@ -245,52 +350,34 @@ def install(platform: str = "claude") -> None:
sys.exit(1) sys.exit(1)
cfg = _PLATFORM_CONFIG[platform] cfg = _PLATFORM_CONFIG[platform]
skill_src = Path(__file__).parent / cfg["skill_file"] project_dir = project_dir or Path(".")
if not skill_src.exists(): skill_dst = _copy_skill_file(platform, project=project, project_dir=project_dir)
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}")
if cfg["claude_md"]: if cfg["claude_md"]:
# Register in ~/.claude/CLAUDE.md (Claude Code only) # Register in the matching Claude Code scope.
claude_md = Path.home() / ".claude" / "CLAUDE.md" 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(): if claude_md.exists():
content = claude_md.read_text(encoding="utf-8") content = claude_md.read_text(encoding="utf-8")
if "graphify" in content: if "graphify" in content:
print(f" CLAUDE.md -> already registered (no change)") print(f" CLAUDE.md -> already registered (no change)")
else: 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}") print(f" CLAUDE.md -> skill registered in {claude_md}")
else: else:
claude_md.parent.mkdir(parents=True, exist_ok=True) 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}") print(f" CLAUDE.md -> created at {claude_md}")
if platform == "opencode": 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 # Refresh version stamps in all other previously-installed skill dirs so
# stale-version warnings don't fire for platforms not explicitly re-installed. # 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()
print("Done. Open your AI coding assistant and type:") print("Done. Open your AI coding assistant and type:")
@@ -301,7 +388,7 @@ def install(platform: str = "claude") -> None:
def _print_install_usage() -> None: def _print_install_usage() -> None:
platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"]) 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}") print(f"Platforms: {platforms}")
@@ -371,21 +458,12 @@ _GEMINI_HOOK = {
} }
def gemini_install(project_dir: Path | None = None) -> None: def gemini_install(project_dir: Path | None = None, *, project: bool = False) -> None:
"""Copy skill file to ~/.gemini/skills/graphify/, write GEMINI.md section, and install BeforeTool hook.""" """Copy skill file, write GEMINI.md section, and install BeforeTool hook."""
# Copy skill file to ~/.gemini/skills/graphify/SKILL.md project_dir = project_dir or Path(".")
# On Windows, Gemini CLI prioritises ~/.agents/skills/ over ~/.gemini/skills/ skill_dst = _copy_skill_file("gemini", project=project, project_dir=project_dir)
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}")
target = (project_dir or Path(".")) / "GEMINI.md" target = project_dir / "GEMINI.md"
if target.exists(): if target.exists():
content = target.read_text(encoding="utf-8") 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 # Always re-install the Gemini hook so an older payload (e.g. pre-issue-#580
# wording) is replaced on upgrade. # 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()
print("Gemini CLI will now check the knowledge graph before answering") print("Gemini CLI will now check the knowledge graph before answering")
print("codebase questions and rebuild it after code changes.") 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") 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 the graphify section from GEMINI.md, uninstall hook, and remove skill file."""
# Remove skill file (mirror the install path detection) project_dir = project_dir or Path(".")
if platform.system() == "Windows": _remove_skill_file("gemini", project=project, project_dir=project_dir)
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
target = (project_dir or Path(".")) / "GEMINI.md" target = project_dir / "GEMINI.md"
if not target.exists(): if not target.exists():
print("No GEMINI.md found in current directory - nothing to do") print("No GEMINI.md found in current directory - nothing to do")
return return
@@ -474,7 +540,7 @@ def gemini_uninstall(project_dir: Path | None = None) -> None:
else: else:
target.unlink() target.unlink()
print(f"GEMINI.md was empty after removal - deleted {target.resolve()}") 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" _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.") 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: def _agents_uninstall(project_dir: Path, platform: str = "") -> None:
"""Remove the graphify section from the local AGENTS.md.""" """Remove the graphify section from the local AGENTS.md."""
target = (project_dir or Path(".")) / "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 to windows platform on Windows, claude elsewhere
default_platform = "windows" if platform.system() == "Windows" else "claude" default_platform = "windows" if platform.system() == "Windows" else "claude"
selected_platform: str | None = None selected_platform: str | None = None
project_scope = False
args = sys.argv[2:] args = sys.argv[2:]
i = 0 i = 0
while i < len(args): while i < len(args):
@@ -1368,7 +1505,10 @@ def main() -> None:
if arg in ("-h", "--help"): if arg in ("-h", "--help"):
_print_install_usage() _print_install_usage()
return return
if arg.startswith("--platform="): if arg == "--project":
project_scope = True
i += 1
elif arg.startswith("--platform="):
candidate = arg.split("=", 1)[1] candidate = arg.split("=", 1)[1]
if selected_platform and selected_platform != candidate: if selected_platform and selected_platform != candidate:
print("error: specify install platform only once", file=sys.stderr) print("error: specify install platform only once", file=sys.stderr)
@@ -1395,25 +1535,63 @@ def main() -> None:
selected_platform = arg selected_platform = arg
i += 1 i += 1
chosen_platform = selected_platform or default_platform 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": elif cmd == "uninstall":
purge = "--purge" in sys.argv[2:] args = sys.argv[2:]
uninstall_all(purge=purge) 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": elif cmd == "claude":
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install": if subcmd == "install":
claude_install() if "--project" in sys.argv[3:]:
_project_install("claude", Path("."))
else:
claude_install()
elif subcmd == "uninstall": elif subcmd == "uninstall":
claude_uninstall() if "--project" in sys.argv[3:]:
_project_uninstall("claude", Path("."))
else:
claude_uninstall()
else: else:
print("Usage: graphify claude [install|uninstall]", file=sys.stderr) print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
sys.exit(1) sys.exit(1)
elif cmd == "gemini": elif cmd == "gemini":
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install": if subcmd == "install":
gemini_install() gemini_install(project=("--project" in sys.argv[3:]))
elif subcmd == "uninstall": elif subcmd == "uninstall":
gemini_uninstall() gemini_uninstall(project=("--project" in sys.argv[3:]))
else: else:
print("Usage: graphify gemini [install|uninstall]", file=sys.stderr) print("Usage: graphify gemini [install|uninstall]", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -1438,22 +1616,16 @@ def main() -> None:
elif cmd == "copilot": elif cmd == "copilot":
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install": if subcmd == "install":
install(platform="copilot") if "--project" in sys.argv[3:]:
_project_install("copilot", Path("."))
else:
install(platform="copilot")
elif subcmd == "uninstall": elif subcmd == "uninstall":
skill_dst = Path.home() / _PLATFORM_CONFIG["copilot"]["skill_dst"] if "--project" in sys.argv[3:]:
removed = [] _project_uninstall("copilot", Path("."))
if skill_dst.exists(): else:
skill_dst.unlink() removed = _remove_skill_file("copilot")
removed.append(f"skill removed: {skill_dst}") print("skill removed" if removed else "nothing to remove")
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")
else: else:
print("Usage: graphify copilot [install|uninstall]", file=sys.stderr) print("Usage: graphify copilot [install|uninstall]", file=sys.stderr)
sys.exit(1) sys.exit(1)
@@ -1469,40 +1641,47 @@ def main() -> None:
elif cmd == "pi": elif cmd == "pi":
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install": if subcmd == "install":
install("pi") if "--project" in sys.argv[3:]:
_project_install("pi", Path("."))
else:
install("pi")
elif subcmd == "uninstall": elif subcmd == "uninstall":
skill_dst = Path.home() / ".pi" / "agent" / "skills" / "graphify" / "SKILL.md" if "--project" in sys.argv[3:]:
if skill_dst.exists(): _project_uninstall("pi", Path("."))
skill_dst.unlink() else:
print(f" skill removed -> {skill_dst}") _remove_skill_file("pi")
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
else: else:
print("Usage: graphify pi [install|uninstall]", file=sys.stderr) print("Usage: graphify pi [install|uninstall]", file=sys.stderr)
sys.exit(1) sys.exit(1)
elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"): elif cmd in ("aider", "codex", "opencode", "claw", "droid", "trae", "trae-cn", "hermes"):
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install": 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": elif subcmd == "uninstall":
_agents_uninstall(Path("."), platform=cmd) if "--project" in sys.argv[3:]:
if cmd == "codex": _project_uninstall(cmd, Path("."))
_uninstall_codex_hook(Path(".")) else:
_agents_uninstall(Path("."), platform=cmd)
if cmd == "codex":
_uninstall_codex_hook(Path("."))
else: else:
print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr) print(f"Usage: graphify {cmd} [install|uninstall]", file=sys.stderr)
sys.exit(1) sys.exit(1)
elif cmd == "antigravity": elif cmd == "antigravity":
subcmd = sys.argv[2] if len(sys.argv) > 2 else "" subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install": if subcmd == "install":
_antigravity_install(Path(".")) if "--project" in sys.argv[3:]:
_project_install("antigravity", Path("."))
else:
_antigravity_install(Path("."))
elif subcmd == "uninstall": elif subcmd == "uninstall":
_antigravity_uninstall(Path(".")) if "--project" in sys.argv[3:]:
_project_uninstall("antigravity", Path("."))
else:
_antigravity_uninstall(Path("."))
else: else:
print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr) print("Usage: graphify antigravity [install|uninstall]", file=sys.stderr)
sys.exit(1) 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() 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): def test_install_help_does_not_install_default(tmp_path, monkeypatch, capsys):
from graphify.__main__ import main from graphify.__main__ import main
monkeypatch.chdir(tmp_path) 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() 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 --- # --- always-on AGENTS.md install/uninstall tests ---
def _agents_install(tmp_path, platform): def _agents_install(tmp_path, platform):