diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc971b89..8495b2d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feat: IBM Bob support (#1725). `graphify install --platform bob` installs the skill to `~/.bob/skills/graphify/` (Bob's global skills dir); `graphify bob install` writes the project-scoped skill to `.bob/skills/graphify/` plus an always-on rules file at `.bob/rules/graphify.md` (Bob loads every file under `.bob/rules/` as always-on context; it has no PreToolUse-hook equivalent, so the rules file is the always-on mechanism). Rides kiro's host-neutral skill bundle; `graphify bob uninstall` and the all-platform `graphify uninstall` remove both files. + ## 0.9.14 (2026-07-12) - Fix: Visual Studio *solution folder* nodes no longer embed the absolute scan path (including the local username) in their `id` and `source_file` (#1789, thanks @fremat79). A solution folder is a virtual grouping, not a file — VS writes its name as both the display name and the "path" — but `extract_sln` resolved it to an absolute filesystem path anyway and keyed the node id off that. The CLI's id-relativization pass only remaps ids of real files in the scan set, so a virtual folder never matched and its absolute id survived into a committed `graph.json` (e.g. `id=/Users//proj/Plugins` instead of `id=plugins`). Solution folders are now detected (name == path) and keyed off the folder name only; real project files still resolve as before. (The earlier fix covered `.csproj`/`.sln` file nodes but missed the virtual folders — this completes it.) diff --git a/README.md b/README.md index 0a883e75b..91c4ac892 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,7 @@ for example `graphify claude install --project` or `graphify codex install --pro | Amp | `graphify amp install` | | Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) | | Kiro IDE/CLI | `graphify kiro install` | +| IBM Bob | `graphify install --platform bob` | | Pi coding agent | `graphify install --platform pi` | | Cursor | `graphify cursor install` | | Devin CLI | `graphify devin install` | @@ -290,6 +291,7 @@ Run this once in your project after building a graph: | Amp | `graphify amp install` | | Agent Skills (cross-framework) | `graphify agents install` (alias `graphify skills install`) | | Kiro IDE/CLI | `graphify kiro install` | +| IBM Bob | `graphify bob install` | | Pi coding agent | `graphify pi install` | | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | @@ -686,6 +688,8 @@ graphify agents install # ~/.agents/skills/ + AGENTS.md (cross-framew graphify agents uninstall graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md (Kiro IDE/CLI) graphify kiro uninstall +graphify bob install # .bob/skills/ + .bob/rules/graphify.md (IBM Bob) +graphify bob uninstall graphify pi install # skill file (Pi coding agent) graphify pi uninstall graphify devin install # skill file + .windsurf/rules/graphify.md (Devin CLI) diff --git a/graphify/__main__.py b/graphify/__main__.py index 82e422e0d..ab8f00021 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -39,6 +39,8 @@ _antigravity_finalize, _antigravity_install, _antigravity_uninstall, + _bob_install, + _bob_uninstall, _canonical_platform, _claude_pretooluse_hooks, _copy_skill_file, @@ -107,6 +109,8 @@ _ANTIGRAVITY_WORKFLOW, _CURSOR_RULE_PATH, _CURSOR_RULE, + _BOB_RULES_PATH, + _BOB_RULES, _DEVIN_RULES_PATH, _DEVIN_RULES, _KILO_PLUGIN_JS, @@ -507,7 +511,7 @@ def _run_cli() -> None: print("Usage: graphify ") print() print("Commands:") - print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)") + print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|bob|pi|devin)") print(" uninstall remove graphify from all detected platforms in one shot") print(" --purge also delete graphify-out/ directory") print(" path \"A\" \"B\" shortest path between two nodes in graph.json") @@ -681,6 +685,8 @@ def _run_cli() -> None: " kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)" ) print(" kiro uninstall remove skill + steering file") + print(" bob install write skill to .bob/skills/graphify/ + rules file (IBM Bob)") + print(" bob uninstall remove skill + rules file") print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)") print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/") print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)") diff --git a/graphify/install.py b/graphify/install.py index b34290eec..b04d6cb33 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -386,6 +386,15 @@ def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") "claude_md": False, "skill_refs": "kiro", }, + "bob": { + # IBM Bob. Reuses kiro's split bundle (host-neutral body + references). + # Bob reads .bob/skills//SKILL.md (project) and ~/.bob/skills/ + # (global), which the generic destination fallback already produces. + "skill_file": "skill-kiro.md", + "skill_dst": Path(".bob") / "skills" / "graphify" / "SKILL.md", + "claude_md": False, + "skill_refs": "kiro", + }, "pi": { "skill_file": "skill-pi.md", "skill_dst": Path(".pi") / "agent" / "skills" / "graphify" / "SKILL.md", @@ -867,6 +876,60 @@ def _kiro_uninstall(project_dir: Path) -> None: steering_dst.unlink() removed.append(str(steering_dst.relative_to(project_dir))) + print("Removed: " + (", ".join(removed) if removed else "nothing to remove")) +# IBM Bob — .bob/skills/graphify/SKILL.md (skill) + .bob/rules/graphify.md +# (always-on rules). Bob loads every file under .bob/rules/ alphabetically as +# always-on context and auto-triggers skills by frontmatter description; it has +# no PreToolUse-hook equivalent, so the rules file is the always-on mechanism. +_BOB_RULES_PATH = Path(".bob") / "rules" / "graphify.md" +_BOB_RULES = """\ +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query ""` (or `graphify path "" ""` / `graphify explain ""`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. +- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context +- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost) +""" +def _bob_install(project_dir: Path) -> None: + """Write graphify skill + always-on rules file for IBM Bob.""" + project_dir = project_dir or Path(".") + + # Skill file + references/ sidecar + .graphify_version stamp via the shared + # progressive-disclosure helper. + _copy_skill_file("bob", project=True, project_dir=project_dir) + + # Rules file → .bob/rules/graphify.md (always-on) + rules_dst = project_dir / _BOB_RULES_PATH + rules_dst.parent.mkdir(parents=True, exist_ok=True) + if rules_dst.exists() and rules_dst.read_text(encoding="utf-8") == _BOB_RULES: + print(" .bob/rules/graphify.md -> already configured (no change)") + else: + # File is wholly graphify-owned. Overwrite on upgrade so older wording + # does not silently linger. + action = "updated" if rules_dst.exists() else "written" + rules_dst.write_text(_BOB_RULES, encoding="utf-8") + print(f" .bob/rules/graphify.md -> always-on rules {action}") + + print() + print("Bob will now read the knowledge graph before every conversation.") + print("Use /graphify to build or update the graph.") +def _bob_uninstall(project_dir: Path) -> None: + """Remove graphify skill + rules file for IBM Bob.""" + project_dir = project_dir or Path(".") + removed = [] + + skill_dst = _platform_skill_destination("bob", project=True, project_dir=project_dir) + if _remove_skill_file("bob", project=True, project_dir=project_dir): + removed.append(str(skill_dst.relative_to(project_dir))) + + rules_dst = project_dir / _BOB_RULES_PATH + if rules_dst.exists(): + rules_dst.unlink() + removed.append(str(rules_dst.relative_to(project_dir))) + print("Removed: " + (", ".join(removed) if removed else "nothing to remove")) def _antigravity_finalize(skill_dst: Path, project_dir: Path) -> None: """Write Antigravity's always-on layer next to an installed skill. @@ -1440,6 +1503,9 @@ def _project_install(platform_name: str, project_dir: Path | None = None) -> Non elif platform_name == "kiro": _kiro_install(project_dir) _print_project_git_add_hint([project_dir / ".kiro"]) + elif platform_name == "bob": + _bob_install(project_dir) + _print_project_git_add_hint([project_dir / ".bob"]) elif platform_name in ("aider", "amp", "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) @@ -1481,6 +1547,8 @@ def _project_uninstall(platform_name: str, project_dir: Path | None = None) -> N _cursor_uninstall(project_dir) elif platform_name == "kiro": _kiro_uninstall(project_dir) + elif platform_name == "bob": + _bob_uninstall(project_dir) elif platform_name in ("aider", "amp", "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) @@ -1668,6 +1736,7 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None: vscode_uninstall(pd) _cursor_uninstall(pd) _kiro_uninstall(pd) + _bob_uninstall(pd) _antigravity_uninstall(pd) # AGENTS.md covers: codex, aider, opencode, claw, droid, trae, trae-cn, hermes, copilot _agents_uninstall(pd) @@ -1858,6 +1927,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals "aider", "amp", "antigravity", + "bob", "claude", "claw", "codebuddy", @@ -2052,6 +2122,15 @@ def dispatch_install_cli(cmd: str) -> bool: else: print("Usage: graphify kiro [install|uninstall]", file=sys.stderr) sys.exit(1) + elif cmd == "bob": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + _bob_install(Path(".")) + elif subcmd == "uninstall": + _bob_uninstall(Path(".")) + else: + print("Usage: graphify bob [install|uninstall]", file=sys.stderr) + sys.exit(1) elif cmd == "devin": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": diff --git a/tests/test_bob.py b/tests/test_bob.py new file mode 100644 index 000000000..f4a088f48 --- /dev/null +++ b/tests/test_bob.py @@ -0,0 +1,204 @@ +"""Tests for graphify bob install / uninstall commands (IBM Bob).""" +from pathlib import Path +import sys +from unittest.mock import patch + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _bob_install_user(tmp_path): + from graphify.__main__ import install + import os + old_cwd = Path.cwd() + try: + os.chdir(tmp_path) + with patch("graphify.__main__.Path.home", return_value=tmp_path): + install(platform="bob") + finally: + os.chdir(old_cwd) + + +def _skill_path_user(tmp_path): + return tmp_path / ".bob" / "skills" / "graphify" / "SKILL.md" + + +def _skill_path_project(project_dir): + return project_dir / ".bob" / "skills" / "graphify" / "SKILL.md" + + +def _rules_path(project_dir): + return project_dir / ".bob" / "rules" / "graphify.md" + + +# --------------------------------------------------------------------------- +# User-scope install (graphify install --platform bob) +# --------------------------------------------------------------------------- + +def test_bob_install_user_creates_skill_file(tmp_path): + """User-scope install copies skill to ~/.bob/skills/graphify/SKILL.md.""" + _bob_install_user(tmp_path) + assert _skill_path_user(tmp_path).exists() + + +def test_bob_install_user_installs_references_sidecar(tmp_path): + """Bob rides kiro's split bundle, so references/ must land next to SKILL.md.""" + _bob_install_user(tmp_path) + refs = _skill_path_user(tmp_path).parent / "references" + assert refs.is_dir() + + +def test_bob_skill_file_references_graphify_query(tmp_path): + """The skill must mention graphify query (query-first policy).""" + _bob_install_user(tmp_path) + content = _skill_path_user(tmp_path).read_text(encoding="utf-8") + assert "graphify query" in content or "/graphify query" in content + + +def test_bob_install_user_does_not_write_rules(tmp_path): + """User-scope install does NOT write .bob/rules/ — that's project-only.""" + _bob_install_user(tmp_path) + assert not _rules_path(tmp_path).exists() + + +# --------------------------------------------------------------------------- +# Project install (graphify bob install) +# --------------------------------------------------------------------------- + +def test_bob_install_creates_skill_and_rules(tmp_path, monkeypatch): + """`graphify bob install` writes .bob/skills/ + .bob/rules/graphify.md.""" + from graphify.__main__ import main + home = tmp_path / "home" + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + monkeypatch.setattr(sys, "argv", ["graphify", "bob", "install"]) + with patch("graphify.__main__.Path.home", return_value=home): + main() + assert _skill_path_project(project).exists() + assert _rules_path(project).exists() + assert not _skill_path_user(home).exists() + + +def test_bob_rules_content_recommends_graphify_query(tmp_path): + """The rules file must use the query-first policy.""" + from graphify.__main__ import _bob_install + _bob_install(tmp_path) + content = _rules_path(tmp_path).read_text(encoding="utf-8") + assert "graphify query" in content + assert "GRAPH_REPORT.md" in content + + +def test_bob_install_idempotent(tmp_path, capsys): + """Installing twice does not change the rules file and prints 'no change'.""" + from graphify.__main__ import _bob_install + _bob_install(tmp_path) + content_first = _rules_path(tmp_path).read_text(encoding="utf-8") + _bob_install(tmp_path) + content_second = _rules_path(tmp_path).read_text(encoding="utf-8") + assert content_first == content_second + assert "no change" in capsys.readouterr().out + + +def test_bob_install_project_flag_hints_git_add(tmp_path, monkeypatch, capsys): + """`graphify install --project --platform bob` prints a git add hint.""" + 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", "bob"]) + with patch("graphify.__main__.Path.home", return_value=home): + main() + out = capsys.readouterr().out + assert "git add" in out + assert _skill_path_project(project).exists() + assert _rules_path(project).exists() + + +# --------------------------------------------------------------------------- +# Uninstall +# --------------------------------------------------------------------------- + +def test_bob_uninstall_removes_skill_and_rules(tmp_path, monkeypatch): + """`graphify bob uninstall` removes both the skill tree and the rules file.""" + from graphify.__main__ import main + home = tmp_path / "home" + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + with patch("graphify.__main__.Path.home", return_value=home): + monkeypatch.setattr(sys, "argv", ["graphify", "bob", "install"]) + main() + monkeypatch.setattr(sys, "argv", ["graphify", "bob", "uninstall"]) + main() + assert not _skill_path_project(project).exists() + assert not _rules_path(project).exists() + + +def test_bob_uninstall_noop_when_not_installed(tmp_path, monkeypatch, capsys): + """Uninstall prints 'nothing to remove' when nothing is installed.""" + from graphify.__main__ import main + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", "bob", "uninstall"]) + with patch("graphify.__main__.Path.home", return_value=tmp_path): + main() + assert "nothing to remove" in capsys.readouterr().out + + +def test_bob_uninstall_does_not_touch_user_scope(tmp_path, monkeypatch): + """Project uninstall must not remove the user-scope skill file.""" + from graphify.__main__ import main + home = tmp_path / "home" + project = tmp_path / "project" + project.mkdir() + user_skill = _skill_path_user(home) + user_skill.parent.mkdir(parents=True, exist_ok=True) + user_skill.write_text("user skill") + monkeypatch.chdir(project) + with patch("graphify.__main__.Path.home", return_value=home): + monkeypatch.setattr(sys, "argv", ["graphify", "bob", "install"]) + main() + monkeypatch.setattr(sys, "argv", ["graphify", "bob", "uninstall"]) + main() + assert user_skill.exists() + + +# --------------------------------------------------------------------------- +# Platform config sanity +# --------------------------------------------------------------------------- + +def test_bob_in_platform_config(): + """bob must be registered in _PLATFORM_CONFIG, riding kiro's bundle.""" + from graphify.__main__ import _PLATFORM_CONFIG + assert "bob" in _PLATFORM_CONFIG + assert _PLATFORM_CONFIG["bob"]["skill_file"] == "skill-kiro.md" + assert _PLATFORM_CONFIG["bob"]["skill_refs"] == "kiro" + assert _PLATFORM_CONFIG["bob"]["claude_md"] is False + + +def test_bob_platform_skill_destination_user_scope(tmp_path): + """User-scope destination must be ~/.bob/skills/graphify/SKILL.md.""" + from graphify.__main__ import _platform_skill_destination + with patch("graphify.__main__.Path.home", return_value=tmp_path): + dst = _platform_skill_destination("bob", project=False) + assert dst == tmp_path / ".bob" / "skills" / "graphify" / "SKILL.md" + + +def test_bob_platform_skill_destination_project_scope(tmp_path): + """Project-scope destination must be /.bob/skills/graphify/SKILL.md.""" + from graphify.__main__ import _platform_skill_destination + dst = _platform_skill_destination("bob", project=True, project_dir=tmp_path) + assert dst == tmp_path / ".bob" / "skills" / "graphify" / "SKILL.md" + + +def test_bob_in_main_help_text(capsys, monkeypatch): + """`graphify --help` must list bob in the platform list and per-platform section.""" + from graphify.__main__ import main + monkeypatch.setattr(sys, "argv", ["graphify", "--help"]) + main() + captured = capsys.readouterr().out + assert "|bob" in captured, "bob missing from `graphify --help` platform list" + assert "bob install" in captured, "`bob install` line missing from help text" + assert "bob uninstall" in captured, "`bob uninstall` line missing from help text"