diff --git a/docs/config.md b/docs/config.md index af122d04..f0359d57 100644 --- a/docs/config.md +++ b/docs/config.md @@ -908,6 +908,7 @@ from any working directory: | `agent.cache_ttl` | string | `"5m"` | Prompt-cache write TTL policy: `5m` (status quo), `1h` (always request the 1-hour TTL), or `auto` (per session at client-build time: sparse-cadence sessions — persistent crons, wakeup loops, spaced chats — get `1h`; dense sessions stay on `5m`). Per-cron-job override via `cache_ttl` in jobs.yaml. See `nerve/agent/cache_policy.py` | | `agent.cache_ttl_excluded_models` | list | `[]` | Model-name substrings that never request the 1h TTL | | `agent.agent_teams` | bool | `true` | Set `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` for the CLI subprocess, which registers the `SendMessage` tool. The Agent tool advertises `SendMessage` for resuming a sub-agent whether or not the flag is set, so with it off the model reaches for a tool that does not exist. Nerve loads no settings files (`setting_sources=[]`), so the env dict is the flag's only route in. Teammates stay opt-in per turn and cost a full context window each; the CLI cannot restore in-process teammates when a session's client is recycled (idle timeout, restart, crash retry) | +| `agent.claude_plugin_dirs` | list | `[]` | Extra local Claude Code plugin directories, passed to the CLI as `--plugin-dir` on top of plugins auto-discovered from `~/.claude` (`enabledPlugins` whose cache dir ships `.mcp.json`). Each dir must contain `.claude-plugin/plugin.json` and may declare `lspServers` (e.g. gopls feeding compiler diagnostics into edit results), `mcpServers`, commands, agents, or skills. Auto-discovery cannot forward marketplace LSP plugins (their cache dirs carry no manifest) and the CLI runs with `setting_sources=[]`, so this key is the only route in for LSP servers. Dirs without a manifest are skipped with a warning. Like every `agent.*` key, YAML edits land via the full config reload (`nerve reload`) and apply to sessions started after it — the MCP-only reload (`mcp_reload` tool, `POST /api/mcp-servers/reload`) re-reads plugin dirs from the in-memory config | | `agent.prompt_rewrite.enabled` | bool | `true` | Offer the first-prompt rewrite feature in the web UI (per-user toggle lives in the composer) | | `agent.prompt_rewrite.model` | string | `""` | Model for prompt rewriting (empty = `agent.model`, the chat model) | | `agent.prompt_rewrite.max_tokens` | int | `1024` | Max tokens for the rewritten prompt | diff --git a/nerve/agent/engine.py b/nerve/agent/engine.py index 08bbe861..d711a953 100644 --- a/nerve/agent/engine.py +++ b/nerve/agent/engine.py @@ -491,7 +491,9 @@ async def initialize(self) -> None: # Load Claude Code plugin directories for SDK plugins field from nerve.config import load_claude_code_plugins - self._claude_code_plugins = load_claude_code_plugins() + self._claude_code_plugins = load_claude_code_plugins( + extra_dirs=self.config.agent.claude_plugin_dirs, + ) # Sync MCP servers to DB for frontend visibility await self._sync_mcp_servers_to_db() @@ -542,7 +544,9 @@ async def reload_mcp_config(self) -> list: # reload sees the same config.yaml + workspace/config/settings.yaml that # startup loaded. self._mcp_servers_cache = load_mcp_servers(self.config.config_dir) - self._claude_code_plugins = load_claude_code_plugins() + self._claude_code_plugins = load_claude_code_plugins( + extra_dirs=self.config.agent.claude_plugin_dirs, + ) await self._sync_mcp_servers_to_db() logger.info( "MCP config reloaded: %d server(s), %d Claude Code plugin(s)", diff --git a/nerve/config.py b/nerve/config.py index dc192688..9460047a 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -887,6 +887,14 @@ class AgentConfig: # message teammates that no longer exist. Set False to restore the CLI # default (no SendMessage, no teams). agent_teams: bool = True + # Extra local Claude Code plugin directories, passed to the CLI as + # --plugin-dir on top of plugins auto-discovered from ~/.claude. Each + # directory must contain .claude-plugin/plugin.json and may declare + # lspServers, mcpServers, commands, agents, or skills. Auto-discovery + # only forwards plugins whose cache dir ships .mcp.json, and the CLI + # runs with setting_sources=[] so it never reads ~/.claude/settings.json + # itself — this key is the only route in for LSP-type plugins. + claude_plugin_dirs: list[str] = field(default_factory=list) prompt_rewrite: PromptRewriteConfig = field(default_factory=PromptRewriteConfig) @property @@ -925,6 +933,7 @@ def from_dict(cls, d: dict) -> AgentConfig: cli_idle_timeout_seconds=d.get("cli_idle_timeout_seconds", 900), background_agent_permissions=d.get("background_agent_permissions", True), agent_teams=d.get("agent_teams", True), + claude_plugin_dirs=_str_list(d.get("claude_plugin_dirs"), clean=True), prompt_rewrite=PromptRewriteConfig.from_dict(d.get("prompt_rewrite") or {}), ) @@ -2444,17 +2453,32 @@ def _get_enabled_claude_code_plugins( def load_claude_code_plugins( claude_dir: Path | None = None, + extra_dirs: list[str] | None = None, ) -> list[dict[str, str]]: """Return SDK-compatible plugin configs for enabled Claude Code plugins. Each entry is ``{"type": "local", "path": ""}`` suitable for - ``ClaudeAgentOptions.plugins``. + ``ClaudeAgentOptions.plugins``. *extra_dirs* (``agent.claude_plugin_dirs``) + are appended after the auto-discovered plugins; a directory without a + ``.claude-plugin/plugin.json`` manifest is skipped with a warning. """ plugins = _get_enabled_claude_code_plugins(claude_dir) result: list[dict[str, str]] = [] for plugin_key, plugin_dir in plugins: logger.debug("Claude Code plugin %s → %s", plugin_key, plugin_dir) result.append({"type": "local", "path": str(plugin_dir)}) + for raw in extra_dirs or []: + # Resolve before handing to --plugin-dir: the CLI subprocess runs in + # the session workspace, not the daemon cwd a relative entry names. + path = Path(raw).expanduser().resolve() + if not (path / ".claude-plugin" / "plugin.json").is_file(): + logger.warning( + "claude_plugin_dirs: %s has no .claude-plugin/plugin.json — skipped", + path, + ) + continue + logger.debug("Claude Code plugin dir (configured) → %s", path) + result.append({"type": "local", "path": str(path)}) return result diff --git a/tests/test_claude_plugins.py b/tests/test_claude_plugins.py new file mode 100644 index 00000000..047862d5 --- /dev/null +++ b/tests/test_claude_plugins.py @@ -0,0 +1,116 @@ +"""Tests for Claude Code plugin discovery and configured plugin dirs.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from nerve.config import AgentConfig, load_claude_code_plugins + + +def _make_claude_dir(tmp_path: Path) -> Path: + claude = tmp_path / ".claude" + (claude / "plugins").mkdir(parents=True) + return claude + + +def _install_cached_plugin(claude: Path, name: str, with_mcp: bool) -> Path: + d = claude / "plugins" / "cache" / "mp" / name / "1.0.0" + d.mkdir(parents=True) + if with_mcp: + (d / ".mcp.json").write_text("{}") + else: + (d / "README.md").write_text("docs only") + return d + + +def _enable(claude: Path, *keys: str) -> None: + (claude / "settings.json").write_text( + json.dumps({"enabledPlugins": {k: True for k in keys}}) + ) + + +def _make_local_plugin(tmp_path: Path, name: str) -> Path: + plug = tmp_path / name + (plug / ".claude-plugin").mkdir(parents=True) + (plug / ".claude-plugin" / "plugin.json").write_text( + json.dumps({"name": name, "version": "1.0.0"}) + ) + return plug + + +class TestAutoDiscovery: + def test_enabled_plugin_with_mcp_json(self, tmp_path: Path) -> None: + claude = _make_claude_dir(tmp_path) + cached = _install_cached_plugin(claude, "alpha", with_mcp=True) + _enable(claude, "alpha@mp") + assert load_claude_code_plugins(claude) == [ + {"type": "local", "path": str(cached)} + ] + + def test_plugin_without_mcp_json_is_not_forwarded(self, tmp_path: Path) -> None: + claude = _make_claude_dir(tmp_path) + _install_cached_plugin(claude, "beta-lsp", with_mcp=False) + _enable(claude, "beta-lsp@mp") + assert load_claude_code_plugins(claude) == [] + + +class TestConfiguredPluginDirs: + def test_extra_dir_with_manifest(self, tmp_path: Path) -> None: + claude = _make_claude_dir(tmp_path) + plug = _make_local_plugin(tmp_path, "gopls-lsp") + assert load_claude_code_plugins(claude, extra_dirs=[str(plug)]) == [ + {"type": "local", "path": str(plug)} + ] + + def test_extra_dir_without_manifest_is_skipped(self, tmp_path: Path) -> None: + claude = _make_claude_dir(tmp_path) + plug = tmp_path / "empty" + plug.mkdir() + assert load_claude_code_plugins(claude, extra_dirs=[str(plug)]) == [] + + def test_missing_extra_dir_is_skipped(self, tmp_path: Path) -> None: + claude = _make_claude_dir(tmp_path) + gone = tmp_path / "does-not-exist" + assert load_claude_code_plugins(claude, extra_dirs=[str(gone)]) == [] + + def test_extra_dirs_follow_discovered(self, tmp_path: Path) -> None: + claude = _make_claude_dir(tmp_path) + cached = _install_cached_plugin(claude, "alpha", with_mcp=True) + _enable(claude, "alpha@mp") + plug = _make_local_plugin(tmp_path, "local-lsp") + assert load_claude_code_plugins(claude, extra_dirs=[str(plug)]) == [ + {"type": "local", "path": str(cached)}, + {"type": "local", "path": str(plug)}, + ] + + def test_expanduser(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + claude = _make_claude_dir(tmp_path) + plug = _make_local_plugin(tmp_path, "home-plug") + rel = "~/" + plug.name + assert load_claude_code_plugins(claude, extra_dirs=[rel]) == [ + {"type": "local", "path": str(plug)} + ] + + def test_relative_dir_resolves_to_absolute(self, tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + claude = _make_claude_dir(tmp_path) + plug = _make_local_plugin(tmp_path, "rel-plug") + assert load_claude_code_plugins(claude, extra_dirs=["rel-plug"]) == [ + {"type": "local", "path": str(plug)} + ] + + +def test_agent_config_parses_claude_plugin_dirs() -> None: + cfg = AgentConfig.from_dict({"claude_plugin_dirs": [" /a/b ", ""]}) + assert cfg.claude_plugin_dirs == ["/a/b"] + + +def test_agent_config_wraps_scalar_as_single_entry() -> None: + cfg = AgentConfig.from_dict({"claude_plugin_dirs": "~/plugins/gopls"}) + assert cfg.claude_plugin_dirs == ["~/plugins/gopls"] + + +def test_agent_config_defaults_to_empty() -> None: + assert AgentConfig.from_dict({}).claude_plugin_dirs == []