feat: CWD-based project detection for CLI-based agents through --project-from-cwd (#836)

This commit is contained in:
Robin Breathe authored and GitHub committed 2025-12-16 16:55:06 +01:00
1 parent 7e138397d3
commit 51041e93f1
4 files changed
+147 -2

No files matched your search

+2 -1
View File
@@ -150,6 +150,7 @@ to get a list of all available options.
Some useful options include:
* `--project <path|name>`: 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 <context>`: specify the operation [context](contexts) in which Serena shall operate
* `--mode <mode>`: specify one or more [modes](modes) to enable (can be passed several times)
* `--enable-web-dashboard <true|false>`: enable or disable the web dashboard (enabled by default)
@@ -227,4 +228,4 @@ Here are some examples of commands you might find useful:
<serena> prompts edit-override prompt-name
```
Explore the full set of commands and options using the CLI itself!
Explore the full set of commands and options using the CLI itself!
+12
View File
@@ -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 -- <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
+52
View File
@@ -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(
+81 -1
View File
@@ -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