From e596319492fa2a7ac47866fb2ed7f1c0ea10c4d4 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 00:28:29 +0200 Subject: [PATCH 1/6] Possibility to load modes and contexts by name from $HOME/.serena --- src/serena/agent.py | 4 +-- src/serena/config/context_mode.py | 55 ++++++++++++++++++++++-------- src/serena/config/serena_config.py | 5 +-- src/serena/constants.py | 17 +++++++-- 4 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 593223f..ece6fdc 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -21,7 +21,7 @@ from sensai.util.logging import LogTime from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import SerenaConfig, ToolSet, get_serena_managed_dir +from serena.config.serena_config import SerenaConfig, ToolSet, get_serena_managed_in_project_dir from serena.constants import ( SERENA_LOG_FORMAT, ) @@ -62,7 +62,7 @@ class LinesRead: class MemoriesManager: def __init__(self, project_root: str): - self._memory_dir = Path(get_serena_managed_dir(project_root)) / "memories" + self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" self._memory_dir.mkdir(parents=True, exist_ok=True) def _get_memory_file_path(self, name: str) -> Path: diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index dfd690a..fcd5790 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -14,7 +14,15 @@ from sensai.util import logging from sensai.util.string import ToStringMixin from serena.config.serena_config import ToolInclusionDefinition -from serena.constants import CONTEXT_YAMLS_DIR, DEFAULT_CONTEXT, DEFAULT_MODES, INTERNAL_MODE_YAMLS_DIR, MODE_YAMLS_DIR +from serena.constants import ( + DEFAULT_CONTEXT, + DEFAULT_MODES, + INTERNAL_MODE_YAMLS_DIR, + SERENAS_OWN_CONTEXT_YAMLS_DIR, + SERENAS_OWN_MODE_YAMLS_DIR, + USER_CONTEXT_YAMLS_DIR, + USER_MODE_YAMLS_DIR, +) if TYPE_CHECKING: pass @@ -64,13 +72,19 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena mode.""" - yaml_path = os.path.join(MODE_YAMLS_DIR, f"{name}.yml") - if not os.path.exists(yaml_path): + fname = f"{name}.yml" + custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname) + if os.path.exists(custom_mode_path): + log.info(f"Loading custom mode {name} from {custom_mode_path}") + return cls.from_yaml(custom_mode_path) + + own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) + if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {MODE_YAMLS_DIR}. You can load custom modes by using from_yaml() instead. " + f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}. You can load custom modes by using from_yaml() instead. " f"Available modes: {cls.list_registered_mode_names()}" ) - return cls.from_yaml(yaml_path) + return cls.from_yaml(own_yaml_path) @classmethod def from_name_internal(cls, name: str) -> Self: @@ -81,9 +95,12 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): return cls.from_yaml(yaml_path) @classmethod - def list_registered_mode_names(cls) -> list[str]: + def list_registered_mode_names(cls, include_user_modes: bool = True) -> list[str]: """Names of all registered modes (from the corresponding YAML files in the serena repo).""" - return sorted([f.stem for f in Path(MODE_YAMLS_DIR).glob("*.yml")]) + modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml")] + if include_user_modes: + modes += [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] + return sorted(set(modes)) @classmethod def load_default_modes(cls) -> list[Self]: @@ -134,13 +151,20 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena context.""" - yaml_path = os.path.join(CONTEXT_YAMLS_DIR, f"{name}.yml") - if not os.path.exists(yaml_path): + fname = f"{name}.yml" + custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname) + if os.path.exists(custom_context_path): + log.info(f"Loading custom context {name} from {custom_context_path}") + return cls.from_yaml(custom_context_path) + + own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) + if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Context {Path(yaml_path).stem} not found in {CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" + f"Context {Path(own_yaml_path).stem} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. " + f"You can load a custom context by using from_yaml() instead.\n" f"Available contexts:\n{cls.list_registered_context_names()}" ) - return cls.from_yaml(yaml_path) + return cls.from_yaml(own_yaml_path) @classmethod def load(cls, name_or_path: str | Path) -> Self: @@ -151,14 +175,17 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): return cls.from_yaml(name_or_path) except FileNotFoundError as e: raise FileNotFoundError( - f"Context {name_or_path} not found in {CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" + f"Context {name_or_path} not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" f"Available contexts:\n{cls.list_registered_context_names()}" ) from e @classmethod - def list_registered_context_names(cls) -> list[str]: + def list_registered_context_names(cls, include_user_contexts: bool = True) -> list[str]: """Names of all registered contexts (from the corresponding YAML files in the serena repo).""" - return sorted([f.stem for f in Path(CONTEXT_YAMLS_DIR).glob("*.yml")]) + contexts = [f.stem for f in Path(SERENAS_OWN_CONTEXT_YAMLS_DIR).glob("*.yml")] + if include_user_contexts: + contexts += [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] + return sorted(set(contexts)) @classmethod def load_default(cls) -> Self: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index f202e64..4fb30ea 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -21,6 +21,7 @@ from serena.constants import ( PROJECT_TEMPLATE_FILE, REPO_ROOT, SELENA_CONFIG_TEMPLATE_FILE, + SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME, ) from serena.util.general import load_yaml, save_yaml @@ -113,7 +114,7 @@ class SerenaConfigError(Exception): pass -def get_serena_managed_dir(project_root: str | Path) -> str: +def get_serena_managed_in_project_dir(project_root: str | Path) -> str: return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) @@ -293,7 +294,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if is_running_in_docker(): return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) else: - config_path = str(Path.home() / SERENA_MANAGED_DIR_NAME / cls.CONFIG_FILE) + config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, cls.CONFIG_FILE) # if the config file does not exist, check if we can migrate it from the old location if not os.path.exists(config_path): diff --git a/src/serena/constants.py b/src/serena/constants.py index 668c2f9..4ed24bb 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -3,15 +3,26 @@ from pathlib import Path _repo_root_path = Path(__file__).parent.parent.parent.resolve() _serena_pkg_path = Path(__file__).parent.resolve() +SERENA_MANAGED_DIR_NAME = ".serena" +_serena_in_home_managed_dir = Path.home() / ".serena" + +SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir) + REPO_ROOT = str(_repo_root_path) PROMPT_TEMPLATES_DIR = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") -CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") -MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") +SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") +"""The contexts that are shipped with the Serena package, i.e. the default contexts.""" +USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts") +"""Contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default one.""" +SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") +"""The modes that are shipped with the Serena package, i.e. the default modes.""" +USER_MODE_YAMLS_DIR = str(_serena_in_home_managed_dir / "modes") +"""Modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODE_YAMLS_DIR, the user mode will override the default one.""" INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes") +"""Internal modes, never overridden by user modes.""" SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard") SERENA_ICON_DIR = str(_serena_pkg_path / "resources" / "icons") -SERENA_MANAGED_DIR_NAME = ".serena" DEFAULT_ENCODING = "utf-8" DEFAULT_CONTEXT = "desktop-app" From 0d4263e91d1df1de826aea99d86d54182ad49c0e Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 10:59:15 +0200 Subject: [PATCH 2/6] Added mode and context templates --- src/serena/config/context_mode.py | 2 +- src/serena/resources/config/contexts/context.template.yml | 4 ++++ src/serena/resources/config/modes/mode.template.yml | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/serena/resources/config/contexts/context.template.yml create mode 100644 src/serena/resources/config/modes/mode.template.yml diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index fcd5790..5cd10e4 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -97,7 +97,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def list_registered_mode_names(cls, include_user_modes: bool = True) -> list[str]: """Names of all registered modes (from the corresponding YAML files in the serena repo).""" - modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml")] + modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml") if f != "mode.template.yml"] if include_user_modes: modes += [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] return sorted(set(modes)) diff --git a/src/serena/resources/config/contexts/context.template.yml b/src/serena/resources/config/contexts/context.template.yml new file mode 100644 index 0000000..52e9453 --- /dev/null +++ b/src/serena/resources/config/contexts/context.template.yml @@ -0,0 +1,4 @@ +# See Serena's documentation for more details on concept of contexts. +description: Description of the context, not used in the code. +prompt: Prompt that will form part of the system prompt/initial instructions for agents started in this context. +excluded_tools: [] \ No newline at end of file diff --git a/src/serena/resources/config/modes/mode.template.yml b/src/serena/resources/config/modes/mode.template.yml new file mode 100644 index 0000000..431afde --- /dev/null +++ b/src/serena/resources/config/modes/mode.template.yml @@ -0,0 +1,4 @@ +# See Serena's documentation for more details on concept of modes. +description: Description of the mode, not used in the code. +prompt: Prompt that will form part of the message sent to the model when activating this mode. +excluded_tools: [] From dbacca6c5b2328eeea1747fc4b2d65c3866b8a86 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 15:07:47 +0200 Subject: [PATCH 3/6] Enhanced serena cli We can manage configs and modes through it now --- pyproject.toml | 6 +- src/serena/cli.py | 618 +++++++++++++++++++---------- src/serena/config/context_mode.py | 71 ++-- src/serena/config/serena_config.py | 40 +- 4 files changed, 485 insertions(+), 250 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 602a3cf..6bcf2bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,8 +38,12 @@ dependencies = [ [project.scripts] serena-mcp-server = "serena.cli:start_mcp_server" -index-project = "serena.cli:index_project" +index-project = "serena.cli:index_project" # deprecated print-system-prompt = "serena.cli:print_system_prompt" +mode = "serena.cli:mode" +context = "serena.cli:context" +project = "serena.cli:project" +help = "serena.cli:get_help" [project.license] text = "MIT" diff --git a/src/serena/cli.py b/src/serena/cli.py index af955ed..0b5396b 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -1,237 +1,451 @@ import os +import shutil +import subprocess +import sys from pathlib import Path -from typing import Literal +from typing import Literal, cast import click from sensai.util import logging from tqdm import tqdm from serena.agent import SerenaAgent -from serena.config.serena_config import SerenaConfig -from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES +from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode +from serena.config.serena_config import ProjectConfig, SerenaConfig +from serena.constants import ( + DEFAULT_CONTEXT, + DEFAULT_MODES, + SERENA_MANAGED_DIR_IN_HOME, + SERENAS_OWN_CONTEXT_YAMLS_DIR, + SERENAS_OWN_MODE_YAMLS_DIR, + USER_CONTEXT_YAMLS_DIR, + USER_MODE_YAMLS_DIR, +) from serena.mcp import SerenaMCPFactorySingleProcess from serena.project import Project +from solidlsp.ls_config import Language log = logging.getLogger(__name__) +# --------------------- Utilities ------------------------------------- + + +def _open_in_editor(path: str) -> None: + """Open the given file in the system's default editor or viewer.""" + editor = os.environ.get("EDITOR") + try: + if editor: + subprocess.run([editor, path], check=False) + elif sys.platform.startswith("win"): + os.startfile(path) + elif sys.platform == "darwin": + subprocess.run(["open", path], check=False) + else: + subprocess.run(["xdg-open", path], check=False) + except Exception as e: + print(f"Failed to open {path}: {e}") + class ProjectType(click.ParamType): + """ParamType allowing either a project name or a path to a project directory.""" + name = "[PROJECT_NAME|PROJECT_PATH]" - def convert(self, value: str, param: click.Parameter | None, ctx: click.Context | None) -> str: + def convert(self, value: str, param, ctx) -> str: path = Path(value).resolve() if path.exists() and path.is_dir(): - return str(path) # Valid path - return value # Assume it's a project name + return str(path) + return value PROJECT_TYPE = ProjectType() -@click.command() -@click.option( - "--project", - "project", - type=PROJECT_TYPE, - default=None, - help="Either an absolute path to the project directory or a name of an already registered project. " - "If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name afterwards.", -) -# Keep --project-file for backwards compatibility -@click.option( - "--project-file", - "project", # Use same destination variable to avoid conflicts - type=PROJECT_TYPE, - default=None, - help="[DEPRECATED] Use --project instead.", -) -# Positional argument for backwards compatibility -@click.argument( - "project_file_arg", - type=PROJECT_TYPE, - required=False, - default=None, - metavar="", # don't display anything since it's deprecated -) -@click.option( - "--context", - type=str, - show_default=True, - default=DEFAULT_CONTEXT, - help="Context to use. This can be a name of a built-in context ('desktop-app', 'agent', 'ide-assistant') " - "or a path to a custom context YAML file.", -) -@click.option( - "--mode", - "modes", - type=str, - multiple=True, - default=DEFAULT_MODES, - show_default=True, - help="Mode(s) to use. This can be names of built-in modes ('planning', 'editing', 'one-shot', 'interactive') " - "or paths to custom mode YAML files. Can be specified multiple times to combine modes.", -) -@click.option( - "--transport", - type=click.Choice(["stdio", "sse"]), - default="stdio", - show_default=True, - help="Transport protocol. If you start the server yourself (as opposed to an MCP Client starting the server), sse is recommended.", -) -@click.option( - "--host", - type=str, - show_default=True, - default="0.0.0.0", - help="Host to bind to (for SSE transport).", -) -@click.option( - "--port", - type=int, - show_default=True, - default=8000, - help="Port to bind to (for SSE transport).", -) -@click.option( - "--enable-web-dashboard", - type=bool, - is_flag=False, - default=None, - help="Whether to enable the web dashboard. If not specified, will take the value from the serena configuration.", -) -@click.option( - "--enable-gui-log-window", - type=bool, - is_flag=False, - default=None, - help="Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. " - "If not specified, will take the value from the serena configuration.", -) -@click.option( - "--log-level", - type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), - default=None, - help="Log level for GUI, dashboard and other logging. If not specified, will take the value from the serena configuration.", -) -@click.option( - "--trace-lsp-communication", - type=bool, - is_flag=False, - default=None, - help="Whether to trace the communication between Serena and the language servers. This is useful for debugging language server issues.", -) -@click.option( - "--tool-timeout", - type=float, - default=None, - help="Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration.", -) -def start_mcp_server( - project: str | None, - project_file_arg: str | None, - context: str = DEFAULT_CONTEXT, - modes: tuple[str, ...] = DEFAULT_MODES, - transport: Literal["stdio", "sse"] = "stdio", - host: str = "0.0.0.0", - port: int = 8000, - enable_web_dashboard: bool | None = None, - enable_gui_log_window: bool | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, -) -> None: - """Starts the Serena MCP server. By default, will not activate any project at startup. - If you want to start with an already active project, use --project to pass the project name or path. - - Use --context to specify the execution environment and --mode to specify behavior mode(s). - The modes may be adjusted after startup (via the corresponding tool), but the context cannot be changed. +class AutoRegisteringGroup(click.Group): """ - # Prioritize the positional argument if provided - # This is for backward compatibility with the old CLI, should be removed in the future! - project_file = project_file_arg if project_file_arg is not None else project + A click.Group subclass that automatically registers any click.Command + attributes defined on the class into the group. - mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) - mcp_server = mcp_factory.create_mcp_server( - host=host, - port=port, - modes=modes, - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, + After initialization, it inspects its own class for attributes that are + instances of click.Command (typically created via @click.command) and + calls self.add_command(cmd) on each. This lets you define your commands + as static methods on the subclass for IDE-friendly organization without + manual registration. + """ + + def __init__(self, name: str, help: str): + super().__init__(name=name, help=help) + # Scan class attributes for click.Command instances and register them. + for attr in dir(self.__class__): + cmd = getattr(self.__class__, attr) + if isinstance(cmd, click.Command): + self.add_command(cmd) + + +class TopLevelCommands(AutoRegisteringGroup): + """Root CLI group containing the core Serena commands.""" + + def __init__(self): + super().__init__(name="serena", help="Serena CLI commands. You can run `subcommand --help` for more info on each command.") + + @staticmethod + @click.command("start-mcp-server", help="Starts the Serena MCP server.") + @click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.") + @click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.") + @click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="") + @click.option( + "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." ) - - # log after server creation such that the log appears in the GUI - if project_file_arg is not None: - log.warning( - "The positional argument for the project file path is deprecated and will be removed in the future! " - "Please pass the project file path via the `--project` option instead.\n" - f"Used path: {project_file}" + @click.option( + "--mode", + "modes", + type=str, + multiple=True, + default=DEFAULT_MODES, + show_default=True, + help="Built-in mode names or paths to custom mode YAMLs.", + ) + @click.option("--transport", type=click.Choice(["stdio", "sse"]), default="stdio", show_default=True, help="Transport protocol.") + @click.option("--host", type=str, default="0.0.0.0", show_default=True) + @click.option("--port", type=int, default=8000, show_default=True) + @click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.") + @click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.") + @click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default=None, + help="Override log level in config.", + ) + @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.") + def start_mcp_server( + project: str | None, + project_file_arg: str | None, + context: str, + modes: tuple[str, ...], + transport: Literal["stdio", "sse"], + host: str, + port: int, + enable_web_dashboard: bool | None, + enable_gui_log_window: bool | None, + log_level: str | None, + trace_lsp_communication: bool | None, + tool_timeout: float | None, + ) -> None: + project_file = project_file_arg or project + factory = SerenaMCPFactorySingleProcess(context=context, project=project_file) + server = factory.create_mcp_server( + host=host, + port=port, + modes=modes, + enable_web_dashboard=enable_web_dashboard, + enable_gui_log_window=enable_gui_log_window, + log_level=log_level, + trace_lsp_communication=trace_lsp_communication, + tool_timeout=tool_timeout, ) + if project_file_arg: + log.warning( + "Positional project arg is deprecated; use --project instead. Used: %s", + project_file, + ) + log.info("Starting MCP server …") + server.run(transport=transport) - log.info("Starting MCP server ...") - - mcp_server.run(transport=transport) - - -@click.command() -@click.argument("project", type=click.Path(exists=True), required=False, default=os.getcwd()) -@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING") -@click.option( - "--only-instructions", - is_flag=True, - help="If set, only print the initial instructions prompt (without prefix or postfix). The prefix and postfix are small additions that are used to" - "make the initial instructions more digestible for an LLM in the context of a system prompt, as opposed to their normal usage within a conversation.", -) -def print_system_prompt(project: str, log_level: str = "WARNING", only_instructions: bool = False) -> None: - """ - Print the system prompt (initial instructions with a potential pre/post-fix) for a project. - """ - prefix_to_instructions_prompt = ( - "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account." + @staticmethod + @click.command("print-system-prompt", help="Print the system prompt for a project.") + @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) + @click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="WARNING", + help="Log level for prompt generation.", ) - postfix_to_instructions_prompt = "You begin the conversation by acknowledging that you understood the above instructions on the symbolic tools and are ready to receive a task. You will not need to call the tool on getting initial instructions." + @click.option("--only-instructions", is_flag=True, help="Print only the initial instructions, without prefix/postfix.") + @click.option( + "--context", type=str, default=DEFAULT_CONTEXT, show_default=True, help="Built-in context name or path to custom context YAML." + ) + @click.option( + "--mode", + "modes", + type=str, + multiple=True, + default=DEFAULT_MODES, + show_default=True, + help="Built-in mode names or paths to custom mode YAMLs.", + ) + def print_system_prompt(project: str, log_level: str, only_instructions: bool, context: str, modes: tuple[str, ...]) -> None: + prefix = "You will receive access to Serena's symbolic tools. Below are instructions for using them, take them into account." + postfix = "You begin by acknowledging that you understood the above instructions and are ready to receive tasks." + from serena.tools.workflow_tools import InitialInstructionsTool - from serena.tools.workflow_tools import InitialInstructionsTool + lvl = logging.getLevelNamesMapping()[log_level.upper()] + logging.configure(level=lvl) + context_instance = SerenaAgentContext.load(context) + mode_instances = [SerenaAgentMode.load(mode) for mode in modes] + agent = SerenaAgent( + project=os.path.abspath(project), + serena_config=SerenaConfig(web_dashboard=False, log_level=lvl), + context=context_instance, + modes=mode_instances, + ) + tool = agent.get_tool(InitialInstructionsTool) + instr = tool.apply() + if only_instructions: + print(instr) + else: + print(f"{prefix}\n{instr}\n{postfix}") - log_level_int = logging.getLevelNamesMapping()[log_level.upper()] - logging.configure(level=log_level_int) - - agent = SerenaAgent(project=os.path.abspath(project), serena_config=SerenaConfig(web_dashboard=False, log_level=log_level_int)) - initial_instructions_tool = agent.get_tool(InitialInstructionsTool) - initial_instructions = initial_instructions_tool.apply() - if only_instructions: - print(initial_instructions) - else: - system_prompt = f"{prefix_to_instructions_prompt}\n{initial_instructions}\n{postfix_to_instructions_prompt}" - print(system_prompt) + @staticmethod + @click.command("help", help="Show help for Serena CLI commands.") + @click.pass_context + def help(ctx: click.Context) -> None: + # ctx.parent is the root invocation context + root = ctx.parent + click.echo(root.get_help()) -@click.command() -@click.argument("project", type=click.Path(exists=True), required=False, default=os.getcwd()) -@click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING") -def index_project(project: str, log_level: str = "INFO") -> None: - """ - Index a project by saving the symbols of files to Serena's language server cache. +class ModeCommands(AutoRegisteringGroup): + """Group for 'mode' subcommands.""" - :param project: the project to index. By default, the current working directory is used. - """ - log_level_int = logging.getLevelNamesMapping()[log_level.upper()] - project_instance = Project.load(os.path.abspath(project)) - print(f"Indexing symbols in project {project}") - ls = project_instance.create_language_server(log_level=log_level_int) - save_after_n_files = 10 - with ls.start_server(): - parsed_files = project_instance.gather_source_files() - files_processed = 0 - pbar = tqdm(parsed_files, disable=False) - for relative_file_path in pbar: - pbar.set_description(f"Indexing ({os.path.basename(relative_file_path)})") - ls.request_document_symbols(relative_file_path, include_body=False) - ls.request_document_symbols(relative_file_path, include_body=True) - files_processed += 1 - if files_processed % save_after_n_files == 0: - ls.save_cache() - ls.save_cache() - print(f"Symbols saved to {ls.cache_path}") + def __init__(self): + super().__init__(name="mode", help="Manage Serena modes.") + + @staticmethod + @click.command("list", help="List available modes.") + def list(): + mode_names = SerenaAgentMode.list_registered_mode_names() + max_len_name = max(len(name) for name in mode_names) if mode_names else 20 + for name in mode_names: + mode_yml_path = SerenaAgentMode.get_path(name) + is_internal = Path(mode_yml_path).is_relative_to(SERENAS_OWN_MODE_YAMLS_DIR) + descriptor = " (internal)" if is_internal else f"(at {mode_yml_path})" + name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" + click.echo(name_descr_string) + + @staticmethod + @click.command("create", help="Create a new mode or copy an internal one.") + @click.option( + "--name", + type=str, + default=None, + help="Name for the new mode. If --from-internal is passed may be left empty to create a mode of the same name, which will then override the internal mode.", + ) + @click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal mode.") + def create(name: str, from_internal: str) -> None: + if not (name or from_internal): + raise click.UsageError("Provide at least one of --name or --from-internal.") + mode_name = name or from_internal + dest = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + src = ( + os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml") + if from_internal + else os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, "mode.template.yml") + ) + if not os.path.exists(src): + raise FileNotFoundError( + f"Internal mode '{from_internal}' not found in {SERENAS_OWN_MODE_YAMLS_DIR}. Available modes: {SerenaAgentMode.list_registered_mode_names()}" + ) + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copyfile(src, dest) + click.echo(f"Created mode '{mode_name}' at {dest}.") + _open_in_editor(dest) + + @staticmethod + @click.command("edit", help="Edit a custom mode YAML file.") + @click.argument("mode_name") + def edit(mode_name: str) -> None: + path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + if not os.path.exists(path): + if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False): + click.echo( + f"Mode '{mode_name}' is an internal mode and cannot be edited directly. " + f"Use 'mode create --from-internal {mode_name}' to create a custom mode that overrides it before editing." + ) + else: + click.echo(f"Custom mode '{mode_name}' not found. Create it with: mode create --name {mode_name}.") + return + _open_in_editor(path) + + @staticmethod + @click.command("delete", help="Delete a custom mode file.") + @click.argument("mode_name") + def delete(mode_name: str) -> None: + path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + if not os.path.exists(path): + click.echo(f"Custom mode '{mode_name}' not found.") + return + os.remove(path) + click.echo(f"Deleted custom mode '{mode_name}'.") + + +class ContextCommands(AutoRegisteringGroup): + """Group for 'context' subcommands.""" + + def __init__(self): + super().__init__(name="context", help="Manage Serena contexts.") + + @staticmethod + @click.command("list", help="List available contexts.") + def list(): + context_names = SerenaAgentContext.list_registered_context_names() + max_len_name = max(len(name) for name in context_names) if context_names else 20 + for name in context_names: + context_yml_path = SerenaAgentContext.get_path(name) + is_internal = Path(context_yml_path).is_relative_to(SERENAS_OWN_CONTEXT_YAMLS_DIR) + descriptor = " (internal)" if is_internal else f"(at {context_yml_path})" + name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" + click.echo(name_descr_string) + + @staticmethod + @click.command("create", help="Create a new context or copy an internal one.") + @click.option( + "--name", + type=str, + default=None, + help="Name for the new context. If --from-internal is passed may be left empty to create a context of the same name, which will then override the internal context", + ) + @click.option("--from-internal", "from_internal", type=str, default=None, help="Copy from an internal context.") + def create(name: str, from_internal: str) -> None: + if not (name or from_internal): + raise click.UsageError("Provide at least one of --name or --from-internal.") + ctx_name = name or from_internal + dest = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{ctx_name}.yml") + src = ( + os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml") + if from_internal + else os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, "context.template.yml") + ) + if not os.path.exists(src): + raise FileNotFoundError( + f"Internal context '{from_internal}' not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. Available contexts: {SerenaAgentContext.list_registered_context_names()}" + ) + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copyfile(src, dest) + click.echo(f"Created context '{ctx_name}' at {dest}.") + _open_in_editor(dest) + + @staticmethod + @click.command("edit", help="Edit a custom context YAML file.") + @click.argument("context_name") + def edit(context_name: str) -> None: + path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") + if not os.path.exists(path): + if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False): + click.echo( + f"Context '{context_name}' is an internal context and cannot be edited directly. " + f"Use 'context create --from-internal {context_name}' to create a custom context that overrides it before editing." + ) + else: + click.echo(f"Custom context '{context_name}' not found. Create it with: context create --name {context_name}.") + return + _open_in_editor(path) + + @staticmethod + @click.command("delete", help="Delete a custom context file.") + @click.argument("context_name") + def delete(context_name: str) -> None: + path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") + if not os.path.exists(path): + click.echo(f"Custom context '{context_name}' not found.") + return + os.remove(path) + click.echo(f"Deleted custom context '{context_name}'.") + + +class SerenaConfigCommands(AutoRegisteringGroup): + """Group for 'config' subcommands.""" + + def __init__(self): + super().__init__(name="config", help="Manage Serena configuration.") + + @staticmethod + @click.command( + "edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found." + ) + def edit() -> None: + config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, "serena_config.yml") + if not os.path.exists(config_path): + SerenaConfig.generate_config_file(config_path) + _open_in_editor(config_path) + + +class ProjectCommands(AutoRegisteringGroup): + """Group for 'project' subcommands.""" + + def __init__(self): + super().__init__(name="project", help="Manage Serena projects.") + + @staticmethod + @click.command("generate-yml", help="Generate a project.yml file.") + @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) + @click.option("--language", type=str, default=None, help="Programming language; inferred if not specified.") + def generate_yml(project_path: str, language: str | None = None) -> None: + yml_path = os.path.join(project_path, ProjectConfig.rel_path_to_project_yml()) + if os.path.exists(yml_path): + raise FileExistsError(f"Project file {yml_path} already exists.") + lang_inst = None + if language: + try: + lang_inst = cast(Language, Language[language.upper()]) + except KeyError: + all_langs = [l.name.lower() for l in Language.iter_all(include_experimental=True)] + raise ValueError(f"Unknown language '{language}'. Supported: {all_langs}") + generated_conf = ProjectConfig.autogenerate(project_root=project_path, project_language=lang_inst) + print(f"Generated project.yml with language {generated_conf.language.value} at {yml_path}.") + + @staticmethod + @click.command("index", help="Index a project by saving symbols to the LSP cache.") + @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) + @click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="WARNING", + help="Log level for indexing.", + ) + def index(project: str, log_level: str = "WARNING") -> None: + ProjectCommands._index_project(project, log_level) + + @staticmethod + @click.command("index-deprecated", help="Deprecated alias for 'project index'.") + @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) + @click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING") + def index_deprecated(project: str, log_level: str = "WARNING") -> None: + click.echo("Deprecated! Use `project index` instead.") + ProjectCommands._index_project(project, log_level) + + @staticmethod + def _index_project(project: str, log_level: str) -> None: + lvl = logging.getLevelNamesMapping()[log_level.upper()] + proj = Project.load(os.path.abspath(project)) + print(f"Indexing symbols in project {project}…") + ls = proj.create_language_server(log_level=lvl) + with ls.start_server(): + files = proj.gather_source_files() + for i, f in enumerate(tqdm(files, desc="Indexing")): + ls.request_document_symbols(f, include_body=False) + ls.request_document_symbols(f, include_body=True) + if (i + 1) % 10 == 0: + ls.save_cache() + ls.save_cache() + print(f"Symbols saved to {ls.cache_path}") + + +# Expose groups so we can reference them in pyproject.toml +mode = ModeCommands() +context = ContextCommands() +project = ProjectCommands() +config = SerenaConfigCommands() + +# Expose toplevel commands for the same reason +top_level = TopLevelCommands() +start_mcp_server = top_level.start_mcp_server +index_project = project.index_deprecated +print_system_prompt = top_level.print_system_prompt + +# needed for the help script to work - register all subcommands to the top-level group +for subgroup in (mode, context, project, config): + top_level.add_command(subgroup) +def get_help() -> str: + """Retrieve the help text for the top-level Serena CLI.""" + return top_level.get_help(click.Context(top_level, info_name="serena")) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 5cd10e4..de84f6a 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -70,21 +70,26 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): return cls(name=name, **data) @classmethod - def from_name(cls, name: str) -> Self: - """Load a registered Serena mode.""" + def get_path(cls, name: str) -> str: + """Get the path to the YAML file for a mode.""" fname = f"{name}.yml" custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname) if os.path.exists(custom_mode_path): - log.info(f"Loading custom mode {name} from {custom_mode_path}") - return cls.from_yaml(custom_mode_path) + return custom_mode_path own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}. You can load custom modes by using from_yaml() instead. " - f"Available modes: {cls.list_registered_mode_names()}" + f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}." + f"Available modes:\n{cls.list_registered_mode_names()}" ) - return cls.from_yaml(own_yaml_path) + return own_yaml_path + + @classmethod + def from_name(cls, name: str) -> Self: + """Load a registered Serena mode.""" + mode_path = cls.get_path(name) + return cls.from_yaml(mode_path) @classmethod def from_name_internal(cls, name: str) -> Self: @@ -97,11 +102,16 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def list_registered_mode_names(cls, include_user_modes: bool = True) -> list[str]: """Names of all registered modes (from the corresponding YAML files in the serena repo).""" - modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml") if f != "mode.template.yml"] + modes = [f.stem for f in Path(SERENAS_OWN_MODE_YAMLS_DIR).glob("*.yml") if f.name != "mode.template.yml"] if include_user_modes: - modes += [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] + modes += cls.list_custom_mode_names() return sorted(set(modes)) + @classmethod + def list_custom_mode_names(cls) -> list[str]: + """Names of all custom modes defined by the user.""" + return [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] + @classmethod def load_default_modes(cls) -> list[Self]: """Load the default modes (interactive and editing).""" @@ -109,10 +119,9 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def load(cls, name_or_path: str | Path) -> Self: - try: - return cls.from_name(str(name_or_path)) - except FileNotFoundError: + if str(name_or_path).endswith(".yml"): return cls.from_yaml(name_or_path) + return cls.from_name(str(name_or_path)) @dataclass(kw_only=True) @@ -149,44 +158,46 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): return cls(name=name, **data) @classmethod - def from_name(cls, name: str) -> Self: - """Load a registered Serena context.""" + def get_path(cls, name: str) -> str: + """Get the path to the YAML file for a context.""" fname = f"{name}.yml" custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname) if os.path.exists(custom_context_path): - log.info(f"Loading custom context {name} from {custom_context_path}") - return cls.from_yaml(custom_context_path) + return custom_context_path own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Context {Path(own_yaml_path).stem} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. " - f"You can load a custom context by using from_yaml() instead.\n" + f"Context {name} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." f"Available contexts:\n{cls.list_registered_context_names()}" ) - return cls.from_yaml(own_yaml_path) + return own_yaml_path + + @classmethod + def from_name(cls, name: str) -> Self: + """Load a registered Serena context.""" + context_path = cls.get_path(name) + return cls.from_yaml(context_path) @classmethod def load(cls, name_or_path: str | Path) -> Self: - try: - return cls.from_name(str(name_or_path)) - except FileNotFoundError: - try: - return cls.from_yaml(name_or_path) - except FileNotFoundError as e: - raise FileNotFoundError( - f"Context {name_or_path} not found in {SERENAS_OWN_CONTEXT_YAMLS_DIR}. You can load a custom context by using from_yaml() instead.\n" - f"Available contexts:\n{cls.list_registered_context_names()}" - ) from e + if str(name_or_path).endswith(".yml"): + return cls.from_yaml(name_or_path) + return cls.from_name(str(name_or_path)) @classmethod def list_registered_context_names(cls, include_user_contexts: bool = True) -> list[str]: """Names of all registered contexts (from the corresponding YAML files in the serena repo).""" contexts = [f.stem for f in Path(SERENAS_OWN_CONTEXT_YAMLS_DIR).glob("*.yml")] if include_user_contexts: - contexts += [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] + contexts += cls.list_custom_context_names() return sorted(set(contexts)) + @classmethod + def list_custom_context_names(cls) -> list[str]: + """Names of all custom contexts defined by the user.""" + return [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] + @classmethod def load_default(cls) -> Self: """Load the default context.""" diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 4fb30ea..0dab08a 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -147,13 +147,16 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): return ["project_name"] @classmethod - def autogenerate(cls, project_root: str | Path, project_name: str | None = None, save_to_disk: bool = True) -> Self: + def autogenerate( + cls, project_root: str | Path, project_name: str | None = None, project_language: Language | None = None, save_to_disk: bool = True + ) -> Self: """ Autogenerate a project configuration for a given project root. :param project_root: the path to the project root :param project_name: the name of the project; if None, the name of the project will be the name of the directory containing the project + :param project_language: the programming language of the project; if None, it will be determined automatically :param save_to_disk: whether to save the project configuration to disk :return: the project configuration """ @@ -161,20 +164,23 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") project_name = project_name or project_root.name - language_composition = determine_programming_language_composition(str(project_root)) - if len(language_composition) == 0: - raise ValueError( - f"No source files found in {project_root}\n\n" - f"To use Serena with this project, you need to either:\n" - f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP)\n" - f"2. Create a project configuration file manually at:\n" - f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" - f"Example project.yml:\n" - f" project_name: {project_name}\n" - f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php\n" - ) - # find the language with the highest percentage - dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) + if project_language is None: + language_composition = determine_programming_language_composition(str(project_root)) + if len(language_composition) == 0: + raise ValueError( + f"No source files found in {project_root}\n\n" + f"To use Serena with this project, you need to either:\n" + f"1. Add source files in one of the supported languages (Python, JavaScript/TypeScript, Java, C#, Rust, Go, Ruby, C++, PHP)\n" + f"2. Create a project configuration file manually at:\n" + f" {os.path.join(project_root, cls.rel_path_to_project_yml())}\n\n" + f"Example project.yml:\n" + f" project_name: {project_name}\n" + f" language: python # or typescript, java, csharp, rust, go, ruby, cpp, php\n" + ) + # find the language with the highest percentage + dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) + else: + dominant_language = project_language.value config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True) config_with_comments["project_name"] = project_name config_with_comments["language"] = dominant_language @@ -276,7 +282,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): return ["config_file_path"] @classmethod - def _generate_config_file(cls, config_file_path: str) -> None: + def generate_config_file(cls, config_file_path: str) -> None: """ Generates a Serena configuration file at the specified path from the template file. @@ -320,7 +326,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if not generate_if_missing: raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") - cls._generate_config_file(config_file_path) + cls.generate_config_file(config_file_path) # load the configuration log.info(f"Loading Serena configuration from {config_file_path}") From 108eded31a38ee512e2656e6557725fc45b95c8d Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 16:00:34 +0200 Subject: [PATCH 4/6] Missing entry in pyproject --- pyproject.toml | 1 + src/serena/cli.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6bcf2bb..9f37d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ print-system-prompt = "serena.cli:print_system_prompt" mode = "serena.cli:mode" context = "serena.cli:context" project = "serena.cli:project" +config = "serena.cli:config" help = "serena.cli:get_help" [project.license] diff --git a/src/serena/cli.py b/src/serena/cli.py index 0b5396b..7b77b2f 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -446,6 +446,8 @@ print_system_prompt = top_level.print_system_prompt # needed for the help script to work - register all subcommands to the top-level group for subgroup in (mode, context, project, config): top_level.add_command(subgroup) + + def get_help() -> str: """Retrieve the help text for the top-level Serena CLI.""" return top_level.get_help(click.Context(top_level, info_name="serena")) From eabf2ac09ff53fbd2588d09a65840199b05f39f1 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 16:10:24 +0200 Subject: [PATCH 5/6] Better help messages in cli --- src/serena/cli.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 7b77b2f..3bfceae 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -86,7 +86,7 @@ class TopLevelCommands(AutoRegisteringGroup): """Root CLI group containing the core Serena commands.""" def __init__(self): - super().__init__(name="serena", help="Serena CLI commands. You can run `subcommand --help` for more info on each command.") + super().__init__(name="serena", help="Serena CLI commands. You can run ` --help` for more info on each command.") @staticmethod @click.command("start-mcp-server", help="Starts the Serena MCP server.") @@ -209,7 +209,7 @@ class ModeCommands(AutoRegisteringGroup): """Group for 'mode' subcommands.""" def __init__(self): - super().__init__(name="mode", help="Manage Serena modes.") + super().__init__(name="mode", help="Manage Serena modes. You can run `mode --help` for more info on each command.") @staticmethod @click.command("list", help="List available modes.") @@ -283,7 +283,9 @@ class ContextCommands(AutoRegisteringGroup): """Group for 'context' subcommands.""" def __init__(self): - super().__init__(name="context", help="Manage Serena contexts.") + super().__init__( + name="context", help="Manage Serena contexts. You can run `context --help` for more info on each command." + ) @staticmethod @click.command("list", help="List available contexts.") @@ -374,7 +376,9 @@ class ProjectCommands(AutoRegisteringGroup): """Group for 'project' subcommands.""" def __init__(self): - super().__init__(name="project", help="Manage Serena projects.") + super().__init__( + name="project", help="Manage Serena projects. You can run `project --help` for more info on each command." + ) @staticmethod @click.command("generate-yml", help="Generate a project.yml file.") From 7a89f32a6a843bb0065bcaa287946df28e0192cf Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Thu, 17 Jul 2025 16:21:06 +0200 Subject: [PATCH 6/6] Typo --- src/serena/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 3bfceae..aa77660 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -219,7 +219,7 @@ class ModeCommands(AutoRegisteringGroup): for name in mode_names: mode_yml_path = SerenaAgentMode.get_path(name) is_internal = Path(mode_yml_path).is_relative_to(SERENAS_OWN_MODE_YAMLS_DIR) - descriptor = " (internal)" if is_internal else f"(at {mode_yml_path})" + descriptor = "(internal)" if is_internal else f"(at {mode_yml_path})" name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" click.echo(name_descr_string) @@ -295,7 +295,7 @@ class ContextCommands(AutoRegisteringGroup): for name in context_names: context_yml_path = SerenaAgentContext.get_path(name) is_internal = Path(context_yml_path).is_relative_to(SERENAS_OWN_CONTEXT_YAMLS_DIR) - descriptor = " (internal)" if is_internal else f"(at {context_yml_path})" + descriptor = "(internal)" if is_internal else f"(at {context_yml_path})" name_descr_string = f"{name:<{max_len_name + 4}}{descriptor}" click.echo(name_descr_string)