mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-15 08:17:15 +00:00
feat: CWD-based project detection for CLI-based agents through --project-from-cwd (#836)
This commit is contained in:
1 parent
7e138397d3
commit
51041e93f1
4 files changed
+147
-2
No files matched your search
@@ -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!
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user