mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 10:57:13 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user