diff --git a/marimo/_cli/pair/commands.py b/marimo/_cli/pair/commands.py index 59f84b0aaa0..95f9ef2531e 100644 --- a/marimo/_cli/pair/commands.py +++ b/marimo/_cli/pair/commands.py @@ -38,13 +38,32 @@ def has_skill(self) -> bool: ) +def _claude_project_roots() -> list[Path]: + """Return the `.claude` directories that apply to the current project. + + Claude Code loads project skills from `.claude/skills` in the directory it + was started in *and in every parent up to the repository root*, so a skill + installed once at the root is available from any subdirectory. Mirror that + walk here; checking only the current directory reports the skill missing + whenever the notebook lives below the root. Outside a repository there is + no root to walk to, so only the current directory applies. + """ + cwd = Path.cwd() + candidates = [cwd, *cwd.parents] + for index, directory in enumerate(candidates): + # A worktree or submodule has `.git` as a file, not a directory. + if (directory / ".git").exists(): + return [d / ".claude" for d in candidates[: index + 1]] + return [cwd / ".claude"] + + def _claude_skill_dirs() -> list[Path]: """Return all directories where a Claude Code skill may be installed. Skills can be installed directly or bundled in a marketplace plugin in - both the global (`~/.claude`) and local (`.claude`) config directories. + both the global (`~/.claude`) and project (`.claude`) config directories. """ - roots = [Path.home() / ".claude", Path.cwd() / ".claude"] + roots = [Path.home() / ".claude", *_claude_project_roots()] subdirs = ["skills", "plugins", str(Path("plugins") / "marketplaces")] return [ *[root / sub for root in roots for sub in subdirs], diff --git a/tests/_cli/test_cli_pair.py b/tests/_cli/test_cli_pair.py index e5354c9f057..c772fc77548 100644 --- a/tests/_cli/test_cli_pair.py +++ b/tests/_cli/test_cli_pair.py @@ -4,18 +4,26 @@ import hashlib import sys from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import patch from click.testing import CliRunner from marimo._cli.cli import main as cli_main from marimo._cli.pair.commands import ( + SKILL_FILE, + SKILL_NAME, AgentConfig, + _claude_project_roots, + _claude_skill_dirs, _opencode_skill_dirs, _plugin_skill_dirs, pair_agents, ) +if TYPE_CHECKING: + import pytest + _runner = CliRunner() TEST_URL = "https://localhost:8000?auth=tok123" @@ -370,3 +378,71 @@ def test_plugin_cache_layout(self, tmp_path: Path) -> None: skill_dirs=_plugin_skill_dirs(tmp_path), ) assert agent.has_skill() is True + + +class TestClaudeProjectRoots: + """Claude Code resolves project skills from the start directory up to the + repository root, so a skill installed once at the root must be found from + any subdirectory below it.""" + + def test_walks_up_to_repository_root( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (tmp_path / ".git").mkdir() + nested = tmp_path / "notebooks" / "analysis" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + + assert _claude_project_roots() == [ + nested / ".claude", + tmp_path / "notebooks" / ".claude", + tmp_path / ".claude", + ] + + def test_skill_at_repository_root_found_from_subdirectory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + (tmp_path / ".git").mkdir() + skill = tmp_path / ".claude" / "skills" / SKILL_NAME + skill.mkdir(parents=True) + (skill / SKILL_FILE).write_text("test") + nested = tmp_path / "notebooks" / "analysis" + nested.mkdir(parents=True) + monkeypatch.chdir(nested) + + agent = AgentConfig( + name="Claude Code", skill_dirs=_claude_skill_dirs() + ) + assert agent.has_skill() is True + + def test_stops_at_repository_root( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + repo = tmp_path / "repo" + (repo / ".git").mkdir(parents=True) + monkeypatch.chdir(repo) + + assert tmp_path / ".claude" not in _claude_project_roots() + + def test_git_file_counts_as_root( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Worktrees and submodules record `.git` as a file. + (tmp_path / ".git").write_text("gitdir: /elsewhere\n") + nested = tmp_path / "sub" + nested.mkdir() + monkeypatch.chdir(nested) + + assert _claude_project_roots() == [ + nested / ".claude", + tmp_path / ".claude", + ] + + def test_outside_repository_uses_cwd_only( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + nested = tmp_path / "no_repo_here" + nested.mkdir() + monkeypatch.chdir(nested) + + assert _claude_project_roots() == [nested / ".claude"]