From e0a9fd524658acc5457c601969a7726dd082eab3 Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Fri, 12 Jun 2026 04:32:58 -0400 Subject: [PATCH 1/2] fix(claude): honor CLAUDE_CONFIG_DIR when listing session history The Claude CLI relocates its entire config dir, including session transcripts under projects/, when CLAUDE_CONFIG_DIR is set. The session pickers always read ~/.claude/projects, so on deployments that set the override (JupyterHub images commonly point it at a persistent volume) both pickers came up empty even though 'claude --resume' saw every session. Resolve the CLI's config dir through one shared util helper and make it the claude_home default for get_sessions_dir and list_all_sessions. The skills directory and spinner-verbs lookups already carried their own copies of the same expression; they now use the helper too, so the override semantics can't drift between surfaces. The helper deliberately does not expanduser the env value (the CLI doesn't) and treats an empty string as unset (matching the CLI's Node-style falsy check). The MCP user config (~/.claude.json) and the plugin cache have the same class of gap but need different relocation logic; tracked separately. Fixes #373 --- CHANGELOG.md | 4 ++ docs/admin-guide.md | 20 ++++---- docs/troubleshooting.md | 2 + notebook_intelligence/claude_sessions.py | 22 +++++---- notebook_intelligence/config.py | 4 +- notebook_intelligence/extension.py | 7 +-- notebook_intelligence/util.py | 13 +++++ tests/test_claude_sessions.py | 61 ++++++++++++++++++++++++ 8 files changed, 107 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2baba4a..ac4f6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ For each release we list user-facing changes grouped as **Added**, **Changed**, - **Provider SDKs load on first use instead of at module import** (#370). `import notebook_intelligence` no longer imports `litellm`, `openai`, `ollama`, or the `anthropic` SDK; `litellm`, `openai`, and `anthropic` load the first time their provider is actually used (for Claude mode that includes the client construction and model refresh NBI runs at startup), while `ollama` still loads during extension startup when the provider enumerates local models. This roughly halves the server-extension import time (a cost the Jupyter server pays on every start), with the biggest effect on Windows machines where antivirus scanning amplifies the many-small-file SDK imports (#368). When NBI does load litellm, it now defaults `LITELLM_LOCAL_MODEL_COST_MAP=true` so litellm reads its bundled model-cost map rather than fetching it over HTTP at import; set the env var to `false` to restore the fetch. +### Fixed + +- **Session history follows `CLAUDE_CONFIG_DIR`** (#373). The chat-sidebar resume picker and the launcher tile's session list always read transcripts from `~/.claude/projects`, so both came up empty when the Claude CLI was configured with `CLAUDE_CONFIG_DIR` and wrote its transcripts elsewhere. The session listing now resolves the CLI's config dir the same way the skills and spinner-verbs paths already did. + ## [5.1.0] - UNRELEASED 5.1.0 builds on 5.0.x with a focus on Claude-mode agent visibility. Tool calls the agent runs now render as persistent status cards with inline diffs and collapsible grouping, the generating indicator can cycle custom verbs, and cancelling a turn tears down the whole process tree the agent spawned instead of leaking it. It also adds two opt-in security guardrails (an MCP stdio-command allowlist and a default-token-password check on shared filesystems) and an always-visible mode for chat feedback. No traitlet, env-var, REST route, or on-disk-format renames or removals; every new admin surface is opt-in and listed below. diff --git a/docs/admin-guide.md b/docs/admin-guide.md index e6a5e6d..c109100 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -46,16 +46,16 @@ Manual edits to `config.json` while JupyterLab is running require a JupyterLab r ## Persistent-volume layout -| Path | Persist? | Notes | -| ----------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `~/.jupyter/nbi/config.json` | Yes | User's chosen provider, models, MCP servers, plus plaintext API keys. Treat as a secret. | -| `~/.jupyter/nbi/user-data.json` | Yes | Encrypted GitHub Copilot access token, written when "remember login" is enabled. Encrypted with `NBI_GH_ACCESS_TOKEN_PASSWORD`. | -| `~/.jupyter/nbi/rules/` | Yes | User's ruleset markdown files. | -| `~/.jupyter/nbi/mcp.json` | Yes | User's MCP server config (alternative to managing via the Settings dialog). | -| `~/.claude/skills/` | Yes | User-scope Claude skills (including managed skills). | -| `~/.claude/projects/` | Yes | Claude Code session transcripts. Required for "Resume previous Claude session". Managed by Claude CLI, not NBI. | -| `/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. | -| Project-scope `/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. | +| Path | Persist? | Notes | +| ----------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.jupyter/nbi/config.json` | Yes | User's chosen provider, models, MCP servers, plus plaintext API keys. Treat as a secret. | +| `~/.jupyter/nbi/user-data.json` | Yes | Encrypted GitHub Copilot access token, written when "remember login" is enabled. Encrypted with `NBI_GH_ACCESS_TOKEN_PASSWORD`. | +| `~/.jupyter/nbi/rules/` | Yes | User's ruleset markdown files. | +| `~/.jupyter/nbi/mcp.json` | Yes | User's MCP server config (alternative to managing via the Settings dialog). | +| `~/.claude/skills/` | Yes | User-scope Claude skills (including managed skills). | +| `~/.claude/projects/` | Yes | Claude Code session transcripts. Required for "Resume previous Claude session". Managed by Claude CLI, not NBI. When `CLAUDE_CONFIG_DIR` is set, this (and `~/.claude/skills/`) lives under `$CLAUDE_CONFIG_DIR` instead; NBI follows the override. | +| `/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. | +| Project-scope `/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. | For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure `~/.jupyter` and `~/.claude` are inside that mount. Anything else (`/tmp`, `~/.cache`) can be ephemeral. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7a774c7..019207b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -112,6 +112,8 @@ ls ~/.claude/skills/ # Claude skills ls ~/.claude/projects/ # Claude session transcripts ``` +If `CLAUDE_CONFIG_DIR` is set, the Claude CLI keeps its skills and session transcripts under `$CLAUDE_CONFIG_DIR` instead of `~/.claude`, and NBI reads from the same place. + > Do not share the contents of `~/.jupyter/nbi/config.json` or `~/.jupyter/nbi/user-data.json` — they contain API keys or your encrypted GitHub token. ## "Skills reloaded" banner keeps appearing diff --git a/notebook_intelligence/claude_sessions.py b/notebook_intelligence/claude_sessions.py index 2c4efe3..763673f 100644 --- a/notebook_intelligence/claude_sessions.py +++ b/notebook_intelligence/claude_sessions.py @@ -4,10 +4,12 @@ Claude Code persists each conversation as a line-delimited JSON file at:: - ~/.claude/projects//.jsonl + /projects//.jsonl -where ```` is the session cwd with path separators replaced by -dashes (e.g. ``/Users/me/proj`` -> ``-Users-me-proj``). +where ```` is ``~/.claude`` unless the CLI's +``CLAUDE_CONFIG_DIR`` env var overrides it, and ```` is the +session cwd with path separators replaced by dashes (e.g. +``/Users/me/proj`` -> ``-Users-me-proj``). This module reads those files for the current Jupyter working directory and returns lightweight metadata (id, timestamps, first user message preview) so @@ -32,6 +34,8 @@ from pathlib import Path from typing import Optional +from notebook_intelligence.util import get_claude_config_dir + log = logging.getLogger(__name__) _PREVIEW_MAX_CHARS = 160 @@ -104,10 +108,10 @@ def encode_cwd(cwd: str) -> str: def get_sessions_dir(cwd: str, claude_home: Optional[str] = None) -> Path: """Return the directory containing session transcripts for ``cwd``. - ``claude_home`` defaults to ``~/.claude`` but can be overridden (useful - for tests and for the ``CLAUDE_CONFIG_DIR`` convention). + ``claude_home`` defaults to the CLI's own config dir (``CLAUDE_CONFIG_DIR`` + when set, else ``~/.claude``) but can be overridden, mainly for tests. """ - home = Path(claude_home) if claude_home else Path.home() / ".claude" + home = Path(claude_home) if claude_home else Path(get_claude_config_dir()) return home / "projects" / encode_cwd(cwd) @@ -393,8 +397,8 @@ def list_all_sessions( ) -> list[ClaudeSessionInfo]: """List every resumable Claude session on disk, newest first. - Walks ``~/.claude/projects/*/`` directly so the result is the same - set of sessions ``claude --resume`` can recover. ``history.jsonl`` is + Walks ``/projects/*/`` directly so the result is the + same set of sessions ``claude --resume`` can recover. ``history.jsonl`` is NOT used as a gating source because recent Claude Code versions don't reliably populate it (notably for SDK-driven invocations), and history-first lookups silently dropped real, on-disk sessions. @@ -412,7 +416,7 @@ def list_all_sessions( activity. Per-transcript parse results are mtime-cached so repeated calls don't reparse every file. """ - home = Path(claude_home) if claude_home else Path.home() / ".claude" + home = Path(claude_home) if claude_home else Path(get_claude_config_dir()) projects_dir = home / "projects" sessions: list[ClaudeSessionInfo] = [] diff --git a/notebook_intelligence/config.py b/notebook_intelligence/config.py index bd0dae7..60f1e58 100644 --- a/notebook_intelligence/config.py +++ b/notebook_intelligence/config.py @@ -8,6 +8,7 @@ import tempfile from typing import Optional +from notebook_intelligence.util import get_claude_config_dir from notebook_intelligence.feature_flags import ( CHAT_MODEL_OVERRIDES, CLAUDE_SETTINGS_OVERRIDES, @@ -219,8 +220,7 @@ def rules_directory(self) -> str: def user_skills_directory(self) -> str: # Mirrors Claude's own CLAUDE_CONFIG_DIR override so the extension picks up # skills from the same directory Claude is using. - base = os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude') - return os.path.join(base, 'skills') + return os.path.join(get_claude_config_dir(), 'skills') def project_skills_directory(self, project_root: str) -> str: return os.path.join(project_root, '.claude', 'skills') diff --git a/notebook_intelligence/extension.py b/notebook_intelligence/extension.py index 75f52d2..d3a118d 100644 --- a/notebook_intelligence/extension.py +++ b/notebook_intelligence/extension.py @@ -63,7 +63,7 @@ ) import notebook_intelligence.github_copilot as github_copilot from notebook_intelligence.built_in_toolsets import built_in_toolsets -from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, VALID_CODING_AGENT_LAUNCHERS, compute_effective_disabled_launchers, validate_coding_agent_launcher_ids, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path, resolve_codex_cli_path, safe_anchor_uri, has_dangerous_text_codepoints, split_csv +from notebook_intelligence.util import ThreadSafeWebSocketConnector, get_claude_config_dir, get_jupyter_root_dir, set_jupyter_root_dir, is_builtin_tool_enabled_in_env, is_provider_enabled_in_env, VALID_CODING_AGENT_LAUNCHERS, compute_effective_disabled_launchers, validate_coding_agent_launcher_ids, resolve_claude_cli_path, resolve_opencode_cli_path, resolve_pi_cli_path, resolve_copilot_cli_path, resolve_codex_cli_path, safe_anchor_uri, has_dangerous_text_codepoints, split_csv from notebook_intelligence.context_factory import RuleContextFactory from notebook_intelligence.skillset import SKILL_NAME_REGEX @@ -428,10 +428,7 @@ def _scrub_credentials_for_wire(claude_settings: dict, string_overrides: dict) - def _read_claude_spinner_verbs() -> Optional[dict]: - settings_path = os.path.join( - os.environ.get('CLAUDE_CONFIG_DIR') or os.path.join(os.path.expanduser('~'), '.claude'), - 'settings.json' - ) + settings_path = os.path.join(get_claude_config_dir(), 'settings.json') try: with open(settings_path) as f: data = json.load(f) diff --git a/notebook_intelligence/util.py b/notebook_intelligence/util.py index 544fc04..fbe4e76 100644 --- a/notebook_intelligence/util.py +++ b/notebook_intelligence/util.py @@ -95,6 +95,19 @@ def safe_jupyter_path(path: str) -> Path: return target_path +def get_claude_config_dir() -> str: + """Claude Code's config dir: CLAUDE_CONFIG_DIR when set, else ``~/.claude``. + + Mirrors the CLI's own override so every surface that reads CLI-owned + state (session transcripts, skills, settings.json) looks where the CLI + actually writes. Read per call rather than memoized: it's a dict lookup, + and tests toggle the env var between cases. + """ + return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join( + os.path.expanduser("~"), ".claude" + ) + + _cached_cli_paths: dict[str, Optional[str]] = {} diff --git a/tests/test_claude_sessions.py b/tests/test_claude_sessions.py index ade4950..f1eb506 100644 --- a/tests/test_claude_sessions.py +++ b/tests/test_claude_sessions.py @@ -974,3 +974,64 @@ def counting_reader(path): second = list_all_sessions(claude_home=str(fake_claude_home)) assert len(second) == 1 assert call_count["n"] == 0 # served from cache + + +class TestClaudeConfigDirDefault: + """The claude_home default must follow CLAUDE_CONFIG_DIR (issue #373). + + The CLI writes transcripts under $CLAUDE_CONFIG_DIR/projects when the + env var is set; the handler calls list_all_sessions without a + claude_home, so the default is what production traffic exercises. + """ + + def test_get_sessions_dir_honors_claude_config_dir( + self, monkeypatch, tmp_path + ): + override = tmp_path / "workspace" / ".claude" + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + result = get_sessions_dir("/some/cwd") + assert result == override / "projects" / encode_cwd("/some/cwd") + + def test_get_sessions_dir_defaults_to_home_claude( + self, monkeypatch, tmp_path + ): + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + result = get_sessions_dir("/some/cwd") + assert result == tmp_path / ".claude" / "projects" / encode_cwd( + "/some/cwd" + ) + + def test_list_all_sessions_reads_claude_config_dir( + self, monkeypatch, tmp_path + ): + override = tmp_path / "workspace" / ".claude" + cwd = str(tmp_path / "proj") + _write_jsonl( + override / "projects" / encode_cwd(cwd) / "abc.jsonl", + [_user_line("abc", "hello from the override dir", cwd=cwd)], + ) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + + sessions = list_all_sessions() + + assert [s.session_id for s in sessions] == ["abc"] + assert sessions[0].preview == "hello from the override dir" + + def test_list_all_sessions_ignores_home_claude_when_overridden( + self, monkeypatch, tmp_path + ): + home = tmp_path / "home" + cwd = str(tmp_path / "proj") + _write_jsonl( + home / ".claude" / "projects" / encode_cwd(cwd) / "old.jsonl", + [_user_line("old", "stale home transcript", cwd=cwd)], + ) + override = tmp_path / "workspace" / ".claude" + override.mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + + assert list_all_sessions() == [] From 07fadc4560b4ea1063b3be5ed0c29d154556197a Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Fri, 12 Jun 2026 05:29:57 -0400 Subject: [PATCH 2/2] fix(claude): honor CLAUDE_CONFIG_DIR for user-scope MCP config and the plugin cache The CLI relocates .claude.json to $CLAUDE_CONFIG_DIR/.claude.json when the override is set, but the MCP manager kept reading (and writing disabledMcpServers to) $HOME/.claude.json while CLI-mediated writes via 'claude mcp add' inherited the env and landed in the relocated file, so reads and writes diverged on override deployments. The plugin cache fallback similarly pointed at ~/.claude/plugins instead of the relocated cache. The .claude.json resolver is a private helper rather than a reuse of util.get_claude_config_dir because its default genuinely differs: the file lives in the home dir itself, not inside ~/.claude. The plugins fallback does route through the shared helper; the CLI gives CLAUDE_CODE_PLUGIN_CACHE_DIR precedence over the config dir and NBI matches that, verified against the CLI bundle and a sandboxed 'claude mcp add' run. Test hardening that the change made necessary: the unittest-style PATCH suite patches Path.home but pytest fixtures don't reach it, so a developer's exported CLAUDE_CONFIG_DIR would have sent the endpoint's write into their real relocated .claude.json; setUp now snapshots and scrubs the env. The Playwright webServer env drops the variable for the same reason, deleting it rather than blanking it because the CLI treats an empty string as a set (degenerate) config dir. Also dates 5.1.0 in the changelog (released 2026-06-08) and adds the missing compare links. Fixes #375 --- CHANGELOG.md | 6 +- docs/admin-guide.md | 6 +- notebook_intelligence/claude_mcp_manager.py | 21 ++++++- notebook_intelligence/plugin_manager.py | 7 ++- tests/test_claude_mcp_manager.py | 65 +++++++++++++++++++++ tests/test_plugin_manager.py | 27 +++++++++ ui-tests/playwright.config.ts | 15 ++++- ui-tests/tests/claude-mcp-patch.spec.ts | 4 +- 8 files changed, 140 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4f6e5..bb302ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ For each release we list user-facing changes grouped as **Added**, **Changed**, ### Fixed - **Session history follows `CLAUDE_CONFIG_DIR`** (#373). The chat-sidebar resume picker and the launcher tile's session list always read transcripts from `~/.claude/projects`, so both came up empty when the Claude CLI was configured with `CLAUDE_CONFIG_DIR` and wrote its transcripts elsewhere. The session listing now resolves the CLI's config dir the same way the skills and spinner-verbs paths already did. +- **User-scope MCP config and the plugin cache follow `CLAUDE_CONFIG_DIR`** (#375). The MCP management tab read user-scope servers from `~/.claude.json` even though the CLI relocates that file to `$CLAUDE_CONFIG_DIR/.claude.json` when the override is set (so reads and CLI-mediated writes diverged), and the Plugins panel's cache fallback pointed at `~/.claude/plugins` instead of the relocated cache. Both now resolve the CLI's actual locations; `CLAUDE_CODE_PLUGIN_CACHE_DIR` still wins for the plugin cache when set. -## [5.1.0] - UNRELEASED +## [5.1.0] - 2026-06-08 5.1.0 builds on 5.0.x with a focus on Claude-mode agent visibility. Tool calls the agent runs now render as persistent status cards with inline diffs and collapsible grouping, the generating indicator can cycle custom verbs, and cancelling a turn tears down the whole process tree the agent spawned instead of leaking it. It also adds two opt-in security guardrails (an MCP stdio-command allowlist and a default-token-password check on shared filesystems) and an always-visible mode for chat feedback. No traitlet, env-var, REST route, or on-disk-format renames or removals; every new admin surface is opt-in and listed below. @@ -382,7 +383,8 @@ A multi-PR accessibility pass landed across most NBI surfaces. Together these ma - Settings UI restructured around Claude vs default mode. - WebSocket connection reliability improvements. -[unreleased]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.1...HEAD +[unreleased]: https://github.com/plmbr/notebook-intelligence/compare/v5.1.0...HEAD +[5.1.0]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.1...v5.1.0 [5.0.1]: https://github.com/plmbr/notebook-intelligence/compare/v5.0.0...v5.0.1 [5.0.0]: https://github.com/plmbr/notebook-intelligence/compare/v4.8.0...v5.0.0 [4.8.0]: https://github.com/plmbr/notebook-intelligence/compare/v4.7.0...v4.8.0 diff --git a/docs/admin-guide.md b/docs/admin-guide.md index c109100..f2c2638 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -57,7 +57,7 @@ Manual edits to `config.json` while JupyterLab is running require a JupyterLab r | `/share/jupyter/nbi/` | No (image) | Org-wide base config. Bake into your container image. | | Project-scope `/.claude/skills/` | Per project | Lives in the user's working directory. Persists if the working directory does. | -For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure `~/.jupyter` and `~/.claude` are inside that mount. Anything else (`/tmp`, `~/.cache`) can be ephemeral. +For Kubeflow or KubeSpawner: mount the user's home directory on a PVC and ensure `~/.jupyter` and `~/.claude` are inside that mount (or `$CLAUDE_CONFIG_DIR`, if you point the Claude CLI elsewhere). Anything else (`/tmp`, `~/.cache`) can be ephemeral. --- @@ -474,7 +474,7 @@ Force-off: - Hides the Claude-mode **MCP Servers** tab in the Settings panel (visible only when Claude mode is on and the `claude` CLI is available). - Returns HTTP 403 from every `/notebook-intelligence/claude-mcp/*` route. -The Claude-mode tab is **independent** of the existing non-Claude **MCP Servers** tab. The former wraps Claude Code's own config (`~/.claude.json` and project `.mcp.json`); the latter manages NBI's own MCP servers used by the non-Claude chat path. They never appear at the same time — the non-Claude tab is hidden when Claude mode is on, and the Claude-mode tab is hidden when it's off. +The Claude-mode tab is **independent** of the existing non-Claude **MCP Servers** tab. The former wraps Claude Code's own config (`~/.claude.json`, relocated to `$CLAUDE_CONFIG_DIR/.claude.json` when that env var is set, and project `.mcp.json`); the latter manages NBI's own MCP servers used by the non-Claude chat path. They never appear at the same time; the non-Claude tab is hidden when Claude mode is on, and the Claude-mode tab is hidden when it's off. Reads come from Claude's JSON config files directly (fast, no health checks). Writes (add / remove) shell out to `claude mcp add` / `claude mcp remove` so Claude remains the source of truth for any side effects (project-trust prompts, OAuth bookkeeping). @@ -516,7 +516,7 @@ c.NotebookIntelligence.claude_plugins_management_policy = "force-off" Or via env: `NBI_CLAUDE_PLUGINS_MANAGEMENT_POLICY=force-off`. -Force-off hides the **Plugins** tab and returns 403 from every `/notebook-intelligence/plugins/*` route. The tab is otherwise visible only when Claude mode is on and the `claude` CLI is available. Both reads (`claude plugin list --json`) and writes (`claude plugin install` / `uninstall` / `enable` / `disable` / `marketplace add` / `marketplace remove`) shell out to the Claude CLI; Claude owns the plugin state under `~/.claude/plugins/`. +Force-off hides the **Plugins** tab and returns 403 from every `/notebook-intelligence/plugins/*` route. The tab is otherwise visible only when Claude mode is on and the `claude` CLI is available. Both reads (`claude plugin list --json`) and writes (`claude plugin install` / `uninstall` / `enable` / `disable` / `marketplace add` / `marketplace remove`) shell out to the Claude CLI; Claude owns the plugin state under `~/.claude/plugins/` (`$CLAUDE_CONFIG_DIR/plugins/` when that env var is set); NBI reads the same location. > **Blast radius.** Force-off only kills the _management UI_ — already-installed plugins keep loading inside Claude Code sessions because Claude's plugin loader doesn't consult NBI's policy. To stop existing plugins from loading, you'd need to remove them on disk or disable them via the `claude plugin disable` CLI before flipping the policy. Force-off prevents user-driven add/remove/enable/disable through NBI; that's the contract. diff --git a/notebook_intelligence/claude_mcp_manager.py b/notebook_intelligence/claude_mcp_manager.py index be2232d..5818eaa 100644 --- a/notebook_intelligence/claude_mcp_manager.py +++ b/notebook_intelligence/claude_mcp_manager.py @@ -8,6 +8,9 @@ - Local scope: ``~/.claude.json`` → ``projects..mcpServers`` - Project scope: ``/.mcp.json`` +When ``CLAUDE_CONFIG_DIR`` is set, the CLI relocates ``.claude.json`` to +``$CLAUDE_CONFIG_DIR/.claude.json`` and the reads follow it there. + Writes go through ``claude mcp add`` / ``claude mcp remove`` so Claude remains the source of truth for any side effects (project-trust prompts, oauth bookkeeping, etc.). The file-based reads are decoupled from the CLI's @@ -21,6 +24,7 @@ import dataclasses import json import logging +import os from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Literal, Optional @@ -127,6 +131,21 @@ def _gather_from_dict( return out +def _claude_user_config_path() -> Path: + """Locate the CLI's ``.claude.json``. + + Unlike skills and transcripts, the default lives in the home dir itself + rather than inside ``~/.claude``, so this can't reuse + ``util.get_claude_config_dir``'s fallback: the CLI keeps the file at + ``$HOME/.claude.json`` normally but relocates it to + ``$CLAUDE_CONFIG_DIR/.claude.json`` when the override is set. + """ + override = os.environ.get("CLAUDE_CONFIG_DIR") + if override: + return Path(override) / ".claude.json" + return Path.home() / ".claude.json" + + class ClaudeMCPManager: # Class-level so the lock is shared across all ClaudeMCPManager instances # within this process. Handlers construct a fresh manager per request, @@ -142,7 +161,7 @@ def __init__( stdio_command_allowlist: Optional[Iterable[str]] = None, ): self._working_dir = Path(working_dir) if working_dir else Path.cwd() - self._user_config_path = Path.home() / ".claude.json" + self._user_config_path = _claude_user_config_path() # An empty allowlist means "no enforcement"; the default keeps # per-user deployments permissive. Admins thread their pinned # regex list in via ``stdio_command_allowlist``. diff --git a/notebook_intelligence/plugin_manager.py b/notebook_intelligence/plugin_manager.py index 748ef0f..f742fb2 100644 --- a/notebook_intelligence/plugin_manager.py +++ b/notebook_intelligence/plugin_manager.py @@ -2,7 +2,8 @@ """Wrapper around Claude Code's `claude plugin` CLI. -Plugin state is owned by Claude Code itself (under `~/.claude/plugins/`). +Plugin state is owned by Claude Code itself (under `~/.claude/plugins/`, +or `$CLAUDE_CONFIG_DIR/plugins/` when that env var is set). NBI shells out for both reads and writes: - `claude plugin list --json` → JSON array of installed plugins @@ -35,7 +36,7 @@ run_claude_cli, validate_scope, ) -from notebook_intelligence.util import resolve_github_token +from notebook_intelligence.util import get_claude_config_dir, resolve_github_token log = logging.getLogger(__name__) @@ -244,7 +245,7 @@ def _claude_plugins_root() -> Path: configured = os.environ.get(CLAUDE_PLUGIN_CACHE_DIR_ENV) if configured: return Path(configured).expanduser() - return Path.home() / ".claude" / "plugins" + return Path(get_claude_config_dir()) / "plugins" def _validate_marketplace_name(name: str) -> str: diff --git a/tests/test_claude_mcp_manager.py b/tests/test_claude_mcp_manager.py index e9fe596..6aa73c0 100644 --- a/tests/test_claude_mcp_manager.py +++ b/tests/test_claude_mcp_manager.py @@ -4,6 +4,7 @@ import asyncio import json +import os from pathlib import Path from unittest.mock import MagicMock, patch @@ -28,6 +29,9 @@ def claude_home(tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + # A developer's exported CLAUDE_CONFIG_DIR would otherwise bypass the + # Path.home seam now that the manager honors the override. + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) return home @@ -832,12 +836,20 @@ def setUp(self): Path, "home", classmethod(lambda cls: self._home) ) self._home_patcher.start() + # unittest-style classes don't get the claude_home fixture's delenv; + # without this, an exported CLAUDE_CONFIG_DIR bypasses the Path.home + # seam and the PATCH writes into the developer's real relocated + # .claude.json. + self._env_patcher = patch.dict(os.environ) + self._env_patcher.start() + os.environ.pop("CLAUDE_CONFIG_DIR", None) self._cwd_patcher = patch.object( ext_module, "get_jupyter_root_dir", lambda: str(self._cwd) ) self._cwd_patcher.start() def tearDown(self): + self._env_patcher.stop() self._home_patcher.stop() self._cwd_patcher.stop() import shutil @@ -896,3 +908,56 @@ def test_patch_rejects_missing_field(self): response = self._fetch_patch("/claude-mcp/user/voicemode", {}) assert response.code == 400 assert b"Missing" in response.body + + +class TestClaudeUserConfigPath: + """`.claude.json` must follow CLAUDE_CONFIG_DIR (issue #375). + + The CLI keeps the file at $HOME/.claude.json by default but relocates + it to $CLAUDE_CONFIG_DIR/.claude.json when the override is set, so a + manager reading the home path on an override deployment sees stale or + missing user-scope servers. + """ + + def test_reads_relocated_config_when_override_set( + self, tmp_path, working_dir, monkeypatch + ): + override = tmp_path / "workspace" / ".claude" + override.mkdir(parents=True) + (override / ".claude.json").write_text( + json.dumps( + { + "mcpServers": { + "relocated": { + "type": "http", + "url": "https://example.com/mcp", + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + + manager = ClaudeMCPManager(working_dir=str(working_dir)) + + assert [s.name for s in manager.list_servers()] == ["relocated"] + + def test_override_wins_over_home_config( + self, claude_home, working_dir, monkeypatch, tmp_path + ): + _write_claude_json( + claude_home, + {"mcpServers": {"home-srv": {"type": "http", "url": "https://h"}}}, + ) + override = tmp_path / "workspace" / ".claude" + override.mkdir(parents=True) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(override)) + + manager = ClaudeMCPManager(working_dir=str(working_dir)) + + assert manager.list_servers() == [] + + def test_defaults_to_home_when_unset(self, claude_home, working_dir): + manager = ClaudeMCPManager(working_dir=str(working_dir)) + assert manager._user_config_path == claude_home / ".claude.json" diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 777603d..91a3e30 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -15,6 +15,7 @@ from notebook_intelligence._claude_cli import redact_argv_for_log from notebook_intelligence.plugin_manager import ( PluginManager, + _claude_plugins_root, is_acceptable_marketplace_source, is_github_marketplace_source, ) @@ -1241,3 +1242,29 @@ def test_passes_through_non_secret_flags(self): # Per-family policy-gate coverage lives in `tests/test_policy_gate.py`, # parametrized across SkillsBaseHandler / ClaudeMCPBaseHandler / # PluginsBaseHandler. + + +class TestClaudePluginsRoot: + """The plugins-root fallback must follow CLAUDE_CONFIG_DIR (issue #375). + + The CLI keeps its plugin cache under the config dir, so when + CLAUDE_CONFIG_DIR is set (and no explicit cache-dir override exists) + the root is $CLAUDE_CONFIG_DIR/plugins, not ~/.claude/plugins. + """ + + def test_falls_back_to_claude_config_dir(self, tmp_path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", raising=False) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg")) + assert _claude_plugins_root() == tmp_path / "cfg" / "plugins" + + def test_explicit_cache_dir_wins_over_config_dir(self, tmp_path, monkeypatch): + monkeypatch.setenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", str(tmp_path / "cache")) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg")) + assert _claude_plugins_root() == tmp_path / "cache" + + def test_defaults_to_home_claude_plugins(self, tmp_path, monkeypatch): + monkeypatch.delenv("CLAUDE_CODE_PLUGIN_CACHE_DIR", raising=False) + monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + assert _claude_plugins_root() == tmp_path / ".claude" / "plugins" diff --git a/ui-tests/playwright.config.ts b/ui-tests/playwright.config.ts index 9b435c8..f60464e 100644 --- a/ui-tests/playwright.config.ts +++ b/ui-tests/playwright.config.ts @@ -8,6 +8,12 @@ // its own; import the config helper from `@playwright/test` directly. import { defineConfig, devices } from '@playwright/test'; +const webServerEnv: { [key: string]: string } = Object.fromEntries( + Object.entries(process.env).filter( + ([key, value]) => key !== 'CLAUDE_CONFIG_DIR' && value !== undefined + ) +) as { [key: string]: string }; + export default defineConfig({ // Tests live next to this config so they can ``import { test, expect } from // '@jupyterlab/galata'`` without long relative paths. @@ -23,7 +29,14 @@ export default defineConfig({ command: 'jlpm start', url: 'http://localhost:8888/lab', timeout: 120_000, - reuseExistingServer: !process.env.CI + reuseExistingServer: !process.env.CI, + // The MCP PATCH spec relies on HOME isolation to keep writes out of the + // developer's real ~/.claude.json; an exported CLAUDE_CONFIG_DIR would + // bypass that seam (the server follows the override), so drop it. Delete + // rather than set to '': the claude CLI treats an empty string as a set + // (and degenerate) config dir, so '' would only cover the server's own + // file reads, not any future spec that shells out to the CLI. + env: webServerEnv }, use: { diff --git a/ui-tests/tests/claude-mcp-patch.spec.ts b/ui-tests/tests/claude-mcp-patch.spec.ts index f7123e0..843167c 100644 --- a/ui-tests/tests/claude-mcp-patch.spec.ts +++ b/ui-tests/tests/claude-mcp-patch.spec.ts @@ -12,7 +12,9 @@ * Requires HOME to point at an isolated directory the test owns; otherwise * the PATCH would mutate the user's real ~/.claude.json. The runner sets * HOME=/tmp/... before invoking jlpm playwright, and the seed file is - * placed by the test itself before each run. + * placed by the test itself before each run. CLAUDE_CONFIG_DIR must not + * leak into the server env (the server follows the override, bypassing + * the HOME seam); playwright.config.ts drops it from the webServer env. */ import { expect, test } from '@jupyterlab/galata';