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
23 changes: 21 additions & 2 deletions marimo/_cli/pair/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
76 changes: 76 additions & 0 deletions tests/_cli/test_cli_pair.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Loading