* Move marketplace plugin updates from hook to /update-agnes-plugins skill The SessionStart hook used to run `agnes refresh-marketplace --quiet`, which performed a full fetch+reset+install cycle on every Claude Code session start. That work was invisible to the user, slowed session startup, and was unrecoverable interactively when something failed. Split the responsibility: - `agnes refresh-marketplace --check` is a new lightweight detector: `git fetch` only, compares local HEAD with remote FETCH_HEAD, emits a Claude Code hook JSON message pointing the user at `/update-agnes-plugins` when the marketplace has changes. No reset, no plugin install/update side effects. - `/update-agnes-plugins` is a new slash command (installed by `agnes init` into `<workspace>/.claude/commands/`) that runs `agnes refresh-marketplace` (default chatty path). Output streams into the Claude Code transcript so the user sees install/update progress and can react to errors interactively. - The SessionStart hook now runs `--check`. Existing workspaces auto-upgrade on next `agnes init` (substring marker matches both the old `--quiet` entry and the new `--check` one). BREAKING: `agnes refresh-marketplace --quiet` is removed. Old hooks calling it silent-noop after the CLI upgrade (the hook's `|| true` swallows the unknown-flag error) until re-init rewrites them. * Point marketplace 'Added to your stack' hint at /update-agnes-plugins The post-install green panel on plugin and skill/agent detail pages referenced the SessionStart auto-install path and a shell-prompt `agnes refresh-marketplace` invocation. With the hook now being detect-only, that copy was misleading — the actual install path is the new slash command. Condensed to a single instruction: "Open a new Claude Code session and run:" followed by `/update-agnes-plugins` in a copy-chip. JS clipboard string updated to match. --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
83 lines
3.4 KiB
Python
83 lines
3.4 KiB
Python
"""Tests for cli/lib/commands.py:install_claude_commands."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from cli.lib.commands import install_claude_commands
|
|
|
|
|
|
def _read_managed_command(workspace: Path) -> str:
|
|
return (workspace / ".claude" / "commands" / "update-agnes-plugins.md").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
|
|
def test_install_writes_slash_command_file(tmp_path):
|
|
"""install_claude_commands writes update-agnes-plugins.md into
|
|
<workspace>/.claude/commands/. The file is the bundled template
|
|
(frontmatter + body) — the test just pins that *something* lands and
|
|
that frontmatter shape is intact, so a future template edit doesn't
|
|
silently lose its description metadata."""
|
|
install_claude_commands(tmp_path)
|
|
body = _read_managed_command(tmp_path)
|
|
assert body.startswith("---"), body[:120]
|
|
assert "description:" in body.split("---", 2)[1], body[:200]
|
|
# The slash command's whole point: invoke `agnes refresh-marketplace`.
|
|
# Pin that the bundled body actually references the command.
|
|
assert "agnes refresh-marketplace" in body
|
|
|
|
|
|
def test_install_creates_commands_dir_when_missing(tmp_path):
|
|
"""Workspace without a .claude/ tree at all → install creates both
|
|
.claude/ and .claude/commands/."""
|
|
assert not (tmp_path / ".claude").exists()
|
|
install_claude_commands(tmp_path)
|
|
assert (tmp_path / ".claude" / "commands").is_dir()
|
|
|
|
|
|
def test_install_overwrites_existing_managed_file(tmp_path):
|
|
"""Even if the workspace has a hand-edited copy of the managed slash
|
|
command, install overwrites with the canonical template — server-
|
|
managed by design (Q2 of the design discussion). Users who want to
|
|
customize copy to a different filename, which is preserved by the
|
|
third-party-untouched test below."""
|
|
cmd_dir = tmp_path / ".claude" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
(cmd_dir / "update-agnes-plugins.md").write_text(
|
|
"USER EDIT THAT MUST BE OVERWRITTEN", encoding="utf-8",
|
|
)
|
|
install_claude_commands(tmp_path)
|
|
body = _read_managed_command(tmp_path)
|
|
assert "USER EDIT" not in body
|
|
assert "agnes refresh-marketplace" in body
|
|
|
|
|
|
def test_install_does_not_touch_other_command_files(tmp_path):
|
|
"""User's own slash commands under .claude/commands/ (e.g.
|
|
my-custom.md, project-specific helpers) must survive install
|
|
untouched. Only the Agnes-managed command files are overwritten."""
|
|
cmd_dir = tmp_path / ".claude" / "commands"
|
|
cmd_dir.mkdir(parents=True)
|
|
custom_path = cmd_dir / "my-custom.md"
|
|
custom_body = "---\ndescription: my own slash command\n---\n\nhello"
|
|
custom_path.write_text(custom_body, encoding="utf-8")
|
|
|
|
install_claude_commands(tmp_path)
|
|
assert custom_path.read_text(encoding="utf-8") == custom_body
|
|
|
|
|
|
def test_install_idempotent(tmp_path):
|
|
"""Two consecutive installs produce identical state. Important
|
|
because `agnes init --force` re-runs the installer and the
|
|
SessionStart hook chain (in some future world where we wire it up)
|
|
might too — neither should accumulate stray files or change content
|
|
on a no-op invocation."""
|
|
install_claude_commands(tmp_path)
|
|
first = _read_managed_command(tmp_path)
|
|
install_claude_commands(tmp_path)
|
|
second = _read_managed_command(tmp_path)
|
|
assert first == second
|
|
# And no extra files appeared.
|
|
cmd_dir_files = sorted(p.name for p in (tmp_path / ".claude" / "commands").iterdir())
|
|
assert cmd_dir_files == ["update-agnes-plugins.md"]
|