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
1 change: 1 addition & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 6 additions & 2 deletions nerve/agent/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)",
Expand Down
26 changes: 25 additions & 1 deletion nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {}),
)

Expand Down Expand Up @@ -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": "<dir>"}`` 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


Expand Down
116 changes: 116 additions & 0 deletions tests/test_claude_plugins.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading