diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 231dd70c..896814db 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -150,6 +150,7 @@ to get a list of all available options. Some useful options include: * `--project `: specify the project to work on by name or path. + * `--project-from-cwd`: auto-detect project from current working directory (searches up for `.serena/project.yml` or `.git`, falls back to CWD). Intended only for CLI-based agents like Claude Code, Gemini and Codex. * `--context `: specify the operation [context](contexts) in which Serena shall operate * `--mode `: specify one or more [modes](modes) to enable (can be passed several times) * `--enable-web-dashboard `: enable or disable the web dashboard (enabled by default) @@ -227,4 +228,4 @@ Here are some examples of commands you might find useful: prompts edit-override prompt-name ``` -Explore the full set of commands and options using the CLI itself! \ No newline at end of file +Explore the full set of commands and options using the CLI itself! diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 74b84a00..b474100a 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -88,6 +88,18 @@ Note: that Serena is configured to work on the current project from the get-go, following Claude Code's mode of operation. +Alternatively, use `--project-from-cwd` for user-level configuration that works across all projects: + +```shell +claude mcp add --scope user serena -- start-mcp-server --context=claude-code --project-from-cwd +``` + +This searches up from the current directory for `.serena/project.yml` or `.git` markers, +falling back to the current directory if neither is found. This makes it suitable for a +single global MCP configuration. The `--project-from-cwd` option is intended for CLI-based agents +(like Claude Code, Gemina or Codex) that invoke Serena from within project directories. + +Be sure to use at least `v1.0.52` of Claude Code (as earlier versions do not read MCP server system prompts upon startup). ### VSCode diff --git a/src/serena/cli.py b/src/serena/cli.py index e7cca2be..2a722ad3 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -5,6 +5,7 @@ import os import shutil import subprocess import sys +from collections.abc import Iterator from logging import Logger from pathlib import Path from typing import Any, Literal @@ -35,6 +36,42 @@ from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) + +def find_project_root(root: str | Path | None = None) -> str: + """Find project root by walking up from CWD. + + Checks for .serena/project.yml first (explicit Serena project), then .git (git root). + Falls back to CWD if no marker is found. + + :param root: If provided, constrains the search to this directory and below + (acts as a virtual filesystem root). Search stops at this boundary. + :return: absolute path to project root (falls back to CWD if no marker found) + """ + current = Path.cwd().resolve() + boundary = Path(root).resolve() if root is not None else None + + def ancestors() -> Iterator[Path]: + """Yield current directory and ancestors up to boundary.""" + yield current + for parent in current.parents: + yield parent + if boundary is not None and parent == boundary: + return + + # First pass: look for .serena + for directory in ancestors(): + if (directory / ".serena" / "project.yml").is_file(): + return str(directory) + + # Second pass: look for .git + for directory in ancestors(): + if (directory / ".git").exists(): # .git can be file (worktree) or dir + return str(directory) + + # Fall back to CWD + return str(current) + + # --------------------- Utilities ------------------------------------- @@ -136,9 +173,16 @@ class TopLevelCommands(AutoRegisteringGroup): ) @click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.") @click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.") + @click.option( + "--project-from-cwd", + is_flag=True, + default=False, + help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.", + ) def start_mcp_server( project: str | None, project_file_arg: str | None, + project_from_cwd: bool | None, context: str, modes: tuple[str, ...], transport: Literal["stdio", "sse", "streamable-http"], @@ -169,6 +213,14 @@ class TopLevelCommands(AutoRegisteringGroup): log.info("Initializing Serena MCP server") log.info("Storing logs in %s", log_path) + + # Handle --project-from-cwd flag + if project_from_cwd: + if project is not None or project_file_arg is not None: + raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument") + project = find_project_root() + log.info("Auto-detected project root: %s", project) + project_file = project_file_arg or project factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index ccdf79a3..893299e6 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -4,11 +4,12 @@ import os import shutil import tempfile import time +from pathlib import Path import pytest from click.testing import CliRunner -from serena.cli import ProjectCommands +from serena.cli import ProjectCommands, TopLevelCommands, find_project_root from serena.config.serena_config import ProjectConfig pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") @@ -262,6 +263,85 @@ class TestProjectCreateHelper: ProjectCommands._create_project(temp_project_dir, None, ("python",)) +class TestFindProjectRoot: + """Tests for find_project_root helper with virtual chroot boundary.""" + + def test_finds_serena_from_subdirectory(self, temp_project_dir): + """Test that .serena/project.yml is found when searching from a subdirectory.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + subdir = os.path.join(temp_project_dir, "src", "nested") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_serena_preferred_over_git(self, temp_project_dir): + """Test that .serena/project.yml takes priority over .git at the same level.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + os.makedirs(os.path.join(temp_project_dir, ".git")) + + original_cwd = os.getcwd() + try: + os.chdir(temp_project_dir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.isdir(os.path.join(result, ".serena")) + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_git_used_as_fallback(self, temp_project_dir): + """Test that .git is found when no .serena exists.""" + os.makedirs(os.path.join(temp_project_dir, ".git")) + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir): + """Test falls back to CWD when no markers exist within boundary.""" + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert os.path.samefile(result, subdir) + finally: + os.chdir(original_cwd) + + +class TestProjectFromCwdMutualExclusivity: + """Tests for --project-from-cwd mutual exclusivity.""" + + def test_project_from_cwd_with_project_flag_fails(self, cli_runner): + """Test that --project-from-cwd with --project raises error.""" + result = cli_runner.invoke( + TopLevelCommands.start_mcp_server, + ["--project-from-cwd", "--project", "/some/path"], + ) + assert result.exit_code != 0 + assert "cannot be used with" in result.output + + if __name__ == "__main__": # For manual testing, you can run this file directly: # uv run pytest test/serena/test_cli_project_commands.py -v