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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Gradata/src/gradata/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1843,6 +1843,16 @@ def cmd_hooks(args):
"""Manage Claude Code hook integration."""
action = args.action
if action == "install":
ide = getattr(args, "ide", "claude-code")
if ide != "claude-code":
# The legacy one-command installer calls `gradata hooks install
# --ide <host>`. Claude Code keeps using the richer hook installer
# below; other hosts are wired through the adapter-based install
# path used by `gradata install --agent <host>`.
args.agent = ide
_cmd_install_agent(args)
return

Comment on lines +1846 to +1855
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

--ide is globally accepted but ignored for non-install actions

Line 2170 adds --ide for all hooks actions, but Line 1846-1855 only applies it in install. hooks uninstall --ide codex is currently accepted and silently ignored, which is misleading and can cause users to think Codex uninstall/status was handled.

Proposed fix
 def cmd_hooks(args):
     """Manage Claude Code hook integration."""
     action = args.action
+    ide = getattr(args, "ide", "claude-code")
+    if action in ("uninstall", "status") and ide != "claude-code":
+        print(
+            "error: --ide is only supported for `hooks install`; "
+            "use `gradata uninstall --agent <host>` for non-claude hosts",
+            file=sys.stderr,
+        )
+        sys.exit(2)
+
     if action == "install":
-        ide = getattr(args, "ide", "claude-code")
         if ide != "claude-code":
             # The legacy one-command installer calls `gradata hooks install
             # --ide <host>`. Claude Code keeps using the richer hook installer
             # below; other hosts are wired through the adapter-based install
             # path used by `gradata install --agent <host>`.
             args.agent = ide
             _cmd_install_agent(args)
             return

Also applies to: 2170-2175

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Gradata/src/gradata/cli.py` around lines 1846 - 1855, The CLI currently
accepts --ide for all hooks actions but only applies it for install; update the
hooks command handling so that args.ide is either respected or rejected
consistently: inside the hooks command dispatcher (where args.ide is read and
where _cmd_install_agent(...) is called), detect non-install actions (e.g.,
uninstall, status) and either map args.ide to args.agent for those actions or
raise/print an error informing the user that --ide is only valid with the
install subcommand; specifically modify the block using getattr(args, "ide",
"claude-code") and the dispatcher that handles hooks to refuse or handle --ide
for non-install commands instead of silently ignoring it, ensuring tests and
help messages reflect the rule.

from gradata.hooks.claude_code import install_hook

project_dir = getattr(args, "project_dir", None)
Expand Down Expand Up @@ -2157,6 +2167,12 @@ def main():

p_hooks = sub.add_parser("hooks", help="Manage Claude Code hook integration")
p_hooks.add_argument("action", choices=["install", "uninstall", "status"], help="Hook action")
p_hooks.add_argument(
"--ide",
choices=["claude-code", "codex"],
default="claude-code",
help="IDE/agent to wire up for one-command installers (default: claude-code)",
)
p_hooks.add_argument(
"--profile",
choices=["minimal", "standard", "strict"],
Expand Down
184 changes: 184 additions & 0 deletions Gradata/tests/test_install_smoke_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

import pytest


REPO_ROOT = Path(__file__).resolve().parents[1]
INSTALLER_JS = REPO_ROOT / "gradata-install" / "bin" / "gradata-install.js"


def _cli_env(home: Path, brain: Path) -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("GRADATA_"):
env.pop(key, None)
env.update(
{
"HOME": str(home),
"USERPROFILE": str(home),
"XDG_CONFIG_HOME": str(home / ".config"),
"BRAIN_DIR": str(brain),
"PYTHONPATH": str(REPO_ROOT / "src"),
}
)
return env


SHOW_HN_SLASH_COMMAND_SKIP = (
"GRA-1680: legacy slash-command/one-command installer only accepts "
"claude-code and codex before Show HN; use `gradata install --agent <host>` "
"for this host until it is promoted into the slash-command path."
)


@pytest.mark.parametrize(
("ide", "expected_config", "expected_text"),
[
("claude-code", ".claude/settings.json", "gradata.hooks.inject_brain_rules"),
("codex", ".codex/config.toml", "gradata:codex"),
pytest.param(
"hermes",
".hermes/config.yaml",
"pre_tool_call",
marks=pytest.mark.skip(reason=SHOW_HN_SLASH_COMMAND_SKIP),
),
pytest.param(
"opencode",
".config/opencode/config.json",
"preTool",
marks=pytest.mark.skip(reason=SHOW_HN_SLASH_COMMAND_SKIP),
),
],
)
def test_hooks_install_smoke_matrix_for_show_hn_hosts(
tmp_path: Path, ide: str, expected_config: str, expected_text: str
) -> None:
"""Slash-command hook install smoke matrix used by Show HN completion notes.

Runs with a mocked HOME/BRAIN_DIR so it proves the installer writes the
host-specific hook config without touching a developer's real agent config.
"""
home = tmp_path / "home"
brain = tmp_path / "brain"
project = tmp_path / "project"
brain.mkdir(parents=True)
project.mkdir()

result = subprocess.run(
[
sys.executable,
"-m",
"gradata.cli",
"hooks",
"install",
"--ide",
ide,
"--project-dir",
str(project),
],
cwd=REPO_ROOT,
env=_cli_env(home, brain),
text=True,
capture_output=True,
check=False,
)

assert result.returncode == 0, result.stderr or result.stdout
config = home / expected_config
assert config.is_file(), f"missing {ide} config at {config}"
text = config.read_text(encoding="utf-8")
assert expected_text in text
if ide != "claude-code":
assert str(brain) in text

if ide == "claude-code":
# Claude's richer hook installer should also copy/register the bundled
# JS assets when project-dir is supplied.
assert (project / ".claude/hooks/user-prompt/handoff-watchdog.js").is_file()
data = json.loads(text)
assert "PreToolUse" in data.get("hooks", {})


@pytest.mark.skipif(shutil.which("node") is None, reason="node runtime not installed")
@pytest.mark.parametrize(
("ide", "expected_hook_args"),
[
("claude-code", ["hooks", "install"]),
("codex", ["hooks", "install", "--ide", "codex"]),
pytest.param(
"hermes",
["hooks", "install", "--ide", "hermes"],
marks=pytest.mark.skip(reason=SHOW_HN_SLASH_COMMAND_SKIP),
),
pytest.param(
"opencode",
["hooks", "install", "--ide", "opencode"],
marks=pytest.mark.skip(reason=SHOW_HN_SLASH_COMMAND_SKIP),
),
],
)
def test_one_command_installer_routes_hook_install_matrix(
tmp_path: Path, ide: str, expected_hook_args: list[str]
) -> None:
"""Legacy one-command installer smoke: Claude + Codex reach hook install.

Python/package installation is stubbed so the test is deterministic and
offline; the assertion is on the actual hook command the JS wrapper invokes.
"""
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
calls = tmp_path / "calls.jsonl"

_write_executable(
fake_bin / "python3",
"""#!/bin/sh
printf '%s\n' "python3 $*" >> "$GRADATA_INSTALL_SMOKE_CALLS"
if [ "$*" = "--version" ]; then
printf '%s\n' "Python 3.11.9"
fi
exit 0
""",
)
_write_executable(
fake_bin / "gradata",
"""#!/bin/sh
printf '%s\n' "gradata $*" >> "$GRADATA_INSTALL_SMOKE_CALLS"
exit 0
""",
)

env = os.environ.copy()
env.update(
{
"HOME": str(tmp_path / "home"),
"USERPROFILE": str(tmp_path / "home"),
"PATH": str(fake_bin),
"GRADATA_INSTALL_SMOKE_CALLS": str(calls),
}
)

result = subprocess.run(
[shutil.which("node") or "node", str(INSTALLER_JS), "install", "--ide", ide],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)

assert result.returncode == 0, result.stderr or result.stdout
assert f"[gradata-install] Target IDE: {ide}" in result.stdout
recorded = calls.read_text(encoding="utf-8").splitlines()
assert "gradata " + " ".join(expected_hook_args) in recorded


def _write_executable(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8")
path.chmod(0o755)
Loading