serena
Semantic code understanding with IDE-like symbol operations. Use when: (1) Large codebase analysis (>50 files), (2) Symbol-level operations (find, rename, refactor), (3) Cross-file reference tracking, (4) Project memory and session persistence, (5) Multi-language semantic navigat
Install
npx skills add https://github.com/Dianel555/DSkills/tree/main/skills/serena
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install dianel555-dskills@llmmart
git clone https://github.com/Dianel555/DSkills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole dianel555/dskills collection as a plugin from our marketplace. Git is the plain clone.
README
Serena CLI
Semantic code understanding with IDE-like symbol operations and Web Dashboard. MCP-independent CLI for code navigation, editing, and project memory.
Features
- Web Dashboard: Real-time configuration monitoring and management
- Symbol Operations: Find, rename, replace, insert symbols with language-aware precision
- Cross-file References: Track symbol usages across entire codebase
- Project Memory: Persist and retrieve project knowledge across sessions
- Extended Tools: Shell commands, config file operations
- Cross-Drive Support: Work with projects on any drive (Windows) or path (Unix)
- Auto Project Registration: Automatically registers projects in global Serena config
Installation
pip install serena-agent typer pyyaml
Quick Start
First-time setup: Launch the Web Dashboard to initialize and register the project:
# Start Web Dashboard (recommended for first-time use)
python -m tools dashboard serve --open-browser
# Or manually open browser after starting
python -m tools dashboard serve
# Then open: http://127.0.0.1:24282/dashboard/index.html
Configuration: Create .env file in skills/serena/ directory:
SERENA_CONTEXT=claude-code
SERENA_MODES=interactive,editing,onboarding
SERENA_PROJECT=.
SERENA_DASHBOARD_ENABLED=true
SERENA_DASHBOARD_PORT=24282
Basic Usage:
# Find a symbol
python -m tools symbol find MyClass --body
# List symbols in file
python -m tools symbol overview src/main.py
# Find references
python -m tools symbol refs MyClass/method
# List available tools
python -m tools workflow tools
Command Structure
python -m tools [GLOBAL OPTIONS] <command> [COMMAND OPTIONS]
Global Options
Global options must be specified before the command:
-p, --project PATH- Project directory (default: current directory, env: SERENA_PROJECT)-c, --context TEXT- Execution context (auto-detected if not specified, env: SERENA_CONTEXT)-m, --mode TEXT- Operation modes (can be specified multiple times, env: SERENA_MODES)
Working with Different Projects
Important: When working with projects in different locations (especially cross-drive on Windows), use --project:
# Correct: Specify project path with --project
python -m tools --project "E:\MyProject" file search "pattern"
python -m tools --project "/home/user/project" symbol find MyClass
python -m tools --project "D:\workspace\app" symbol refs MyClass
# Incorrect: Don't use --path with absolute paths from different drives
python -m tools file search "pattern" --path "E:\MyProject" # Will fail!
Why? The --path option in subcommands expects relative paths within the project. Always use --project to set the project root first.
CLI Commands
Dashboard Commands
| Command | Description |
|---|---|
dashboard serve [--open-browser] [--browser-cmd <path>] |
Start Web Dashboard server |
dashboard info |
Show current configuration overview |
dashboard tools |
List active and available tools |
dashboard modes |
List active and available modes |
dashboard contexts |
List active and available contexts |
Dashboard Options:
--open-browser/--no-open-browser: Auto-open browser (default: False)--browser-cmd <path>: Specify browser executable path--host <address>: Listen address (default: 127.0.0.1)--port <number>: Listen port (default: 24282, 0 for auto-select)SERENA_BROWSER_CMD: Environment variable for browser command
Symbol Operations
| Command | Description |
|---|---|
symbol find <name> [--body] [--depth N] [--path file] |
Find symbols by name |
symbol overview <path> |
List all symbols in file |
symbol refs <name> [--path file] |
Find symbol references |
symbol replace <name> --path <file> --body <code> |
Replace symbol body |
symbol insert-after <name> --path <file> --content <code> |
Insert after symbol |
symbol insert-before <name> --path <file> --content <code> |
Insert before symbol |
symbol rename <name> <new> --path <file> |
Rename symbol |
Memory Operations
| Command | Description |
|---|---|
memory list |
List all memories |
memory read <name> |
Read memory content |
memory write <name> --content <text> |
Create/update memory |
memory edit <name> --content <text> |
Edit memory |
memory delete <name> |
Delete memory |
File Operations
| Command | Description |
|---|---|
file list [--path <dir>] [--recursive] |
List directory contents |
file find <pattern> |
Find files by glob pattern |
file search <pattern> [--path <dir>] |
Search for regex pattern in files |
Extended Tools
| Command | Description |
|---|---|
cmd run <cmd> [--cwd <dir>] [--timeout <N>] |
Execute shell command |
cmd script <path> [--args "..."] |
Execute script file |
config read <path> [--format json\|yaml] |
Read JSON/YAML config |
config update <path> <key> <value> |
Update config value |
Workflow
| Command | Description |
|---|---|
workflow onboarding |
Run project onboarding |
workflow check |
Check onboarding status |
workflow tools [--scope all] |
List available tools |
Output Format
All CLI output is JSON:
// Success
{"result": <data>}
// Error
{"error": {"code": "ERROR_CODE", "message": "description"}}
Error codes: INVALID_ARGS, TOOL_NOT_FOUND, INIT_FAILED, RUNTIME_ERROR
Web Dashboard Features
The Web Dashboard provides:
- Real-time Configuration: View active context, modes, and tools
- Project Management: See registered projects and active project
- Tool Monitoring: Track active and available tools
- Configuration Editing: Edit
.envfile directly from browser - Auto Registration: Automatically adds project to
~/.serena/serena_config.yml
Access the dashboard at: http://127.0.0.1:24282/dashboard/index.html
Project Structure
skills/serena/
├── SKILL.md
├── README.md
├── .env.example
└── tools/
├── core.py # SerenaCore wrapper
├── paths.py # Path utilities
├── output.py # JSON output utilities
├── cli/ # Typer CLI commands
│ ├── dashboard.py
│ ├── symbol.py
│ ├── memory.py
│ ├── file.py
│ ├── workflow.py
│ ├── cmd.py
│ └── config.py
├── server/ # Web Dashboard server
│ └── dashboard_server.py # Flask HTTP server
└── extended/ # Extended tools
├── cmd_tools.py
└── config_tools.py
License
MIT
Skill manifest
Serena - Semantic Code Understanding
IDE-like semantic code operations via CLI. Provides symbol-level code navigation, editing, and project memory.
Prerequisites
pip install serena-agent typer pyyaml
Quick Start
First-time setup: Launch the Web Dashboard to initialize and register the project:
python -m tools dashboard serve --open-browser
This will:
- Initialize Serena configuration
- Register the current project in
~/.serena/serena_config.yml - Open the Web Dashboard for monitoring and configuration
Configuration: Edit .env file in skills/serena/ directory:
SERENA_CONTEXT=claude-code
SERENA_MODES=interactive,editing,onboarding
SERENA_PROJECT=.
Usage
Basic Command Structure
python -m tools [GLOBAL OPTIONS] <command> [COMMAND OPTIONS]
Global Options (must be specified before the command):
-p, --project PATH- Project directory (default: current directory, env: SERENA_PROJECT)-c, --context TEXT- Execution context (auto-detected if not specified, env: SERENA_CONTEXT)-m, --mode TEXT- Operation modes (can be specified multiple times, env: SERENA_MODES)
Working with Different Projects
Important: When working with projects in different locations (especially cross-drive on Windows), use --project:
# Correct: Use --project for different project locations
python -m tools --project "/path/to/project" symbol find MyClass
python -m tools --project "E:\MyProject" file search "pattern"
# Incorrect: Don't use --path with absolute paths from different drives
python -m tools file search "pattern" --path "E:\MyProject" # Will fail!
The --path option in subcommands expects relative paths within the project. Always use --project to set the project root first.
Common Operations
# Dashboard
python -m tools dashboard serve --open-browser
python -m tools dashboard info
# Symbol operations
python -m tools symbol find MyClass --body
python -m tools symbol overview src/main.py
python -m tools symbol refs MyClass/method
python -m tools symbol rename OldName NewName --path src/file.py
# Memory operations
python -m tools memory list
python -m tools memory read project_overview
python -m tools memory write api_notes --content "..."
# File operations
python -m tools file list --recursive
python -m tools file find "**/*.py"
python -m tools file search "TODO:.*" --path src
# Extended tools
python -m tools cmd run "git status"
python -m tools config read config.json
Tool Routing Policy
Prefer Serena Over Built-in Tools
| Task | Avoid | Use Serena CLI |
|---|---|---|
| Find function | grep "def func" |
symbol find func --body |
| List file structure | cat file.py |
symbol overview file.py |
| Find usages | grep "func(" |
symbol refs func |
| Edit function | Edit tool |
symbol replace func --path file.py |
| Rename | Manual find/replace | symbol rename old new --path file.py |
When to Use Built-in Tools
- Simple text search (non-code patterns)
- Configuration files (JSON, YAML)
- Documentation files (Markdown)
Command Reference
Dashboard Commands
| Command | Description |
|---|---|
dashboard serve [--open-browser] [--browser-cmd <path>] |
Start Web Dashboard server |
dashboard info |
Show current configuration |
dashboard tools |
List active and available tools |
dashboard modes |
List active and available modes |
dashboard contexts |
List active and available contexts |
Symbol Commands
| Command | Description |
|---|---|
symbol find <name> [--body] [--depth N] [--path file] |
Find symbols by name |
symbol overview <path> |
List all symbols in file |
symbol refs <name> [--path file] |
Find symbol references |
symbol replace <name> --path <file> --body <code> |
Replace symbol body |
symbol insert-after <name> --path <file> --content <code> |
Insert after symbol |
symbol insert-before <name> --path <file> --content <code> |
Insert before symbol |
symbol rename <name> <new> --path <file> |
Rename symbol |
Memory Commands
| Command | Description |
|---|---|
memory list |
List all memories |
memory read <name> |
Read memory content |
memory write <name> --content <text> |
Create/update memory |
memory edit <name> --content <text> |
Edit memory |
memory delete <name> |
Delete memory |
File Commands
| Command | Description |
|---|---|
file list [--path dir] [--recursive] |
List directory |
file find <pattern> |
Find files by glob pattern |
file search <pattern> [--path dir] |
Search for regex pattern |
Extended Commands
| Command | Description |
|---|---|
cmd run <command> [--cwd dir] [--timeout N] |
Execute shell command |
cmd script <path> [--args "..."] |
Execute script file |
config read <path> [--format json\|yaml] |
Read config file |
config update <path> <key> <value> |
Update config value |
Workflow Commands
| Command | Description |
|---|---|
workflow onboarding |
Run project onboarding |
workflow check |
Check onboarding status |
workflow tools [--scope all] |
List available tools |
Workflow Examples
Phase 1: Exploration
python -m tools symbol overview src/main.py # Understand file structure
python -m tools symbol find MyClass --depth 1 # Explore class members
python -m tools symbol find MyClass/method --body # Get implementation details
Phase 2: Analysis
python -m tools symbol refs MyClass/method # Impact analysis
python -m tools memory list # Check project knowledge
python -m tools memory read architecture # Retrieve context
Phase 3: Modification
python -m tools symbol find target --body # Verify target
python -m tools symbol replace target --path f --body "..." # Edit
python -m tools symbol rename old new --path f # Refactor
Error Handling
All CLI output is JSON:
// Success
{"result": <data>}
// Error
{"error": {"code": "ERROR_CODE", "message": "description"}}
| Error Code | Recovery |
|---|---|
INVALID_ARGS |
Check --help |
TOOL_NOT_FOUND |
Use workflow tools |
INIT_FAILED |
Check serena-agent installation |
RUNTIME_ERROR |
Check error message |
Anti-Patterns
| Prohibited | Correct |
|---|---|
| Read entire file to find function | symbol find func --body |
| Grep for function calls | symbol refs func |
| Manual search-replace rename | symbol rename old new --path f |
| Skip impact analysis | symbol refs before editing |
Files (dskills)
-
tools
-
cli
-
cmd.py 1 KB
"""Command execution command group.""" import typer from typing import Optional from . import State from ..output import output_json app = typer.Typer(help="Command execution operations") @app.command("run") def run_command( command: str = typer.Argument(..., help="Shell command to execute"), cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"), timeout: int = typer.Option(300, "--timeout", "-t", help="Timeout in seconds"), ): """Execute a shell command.""" result = State.core.call_tool( "run_command", command=command, cwd=cwd, timeout=timeout, ) output_json(result) @app.command("script") def run_script( script_path: str = typer.Argument(..., help="Path to script file"), args: Optional[str] = typer.Option(None, "--args", "-a", help="Script arguments"), ): """Execute a script file.""" result = State.core.call_tool( "run_script", script_path=script_path, args=args, ) output_json(result) -
config.py 1.1 KB
"""Configuration file operations command group.""" import typer from typing import Optional from . import State from ..output import output_json app = typer.Typer(help="Configuration file operations") @app.command("read") def read_config( path: str = typer.Argument(..., help="Path to configuration file"), format: Optional[str] = typer.Option(None, "--format", "-f", help="Format: json or yaml (auto-detected if omitted)"), ): """Read a configuration file.""" result = State.core.call_tool( "read_config", path=path, format=format, ) output_json(result) @app.command("update") def update_config( path: str = typer.Argument(..., help="Path to configuration file"), key: str = typer.Argument(..., help="Dot-notation key (e.g., 'server.port')"), value: str = typer.Argument(..., help="New value (parsed as JSON if possible)"), ): """Update a configuration file key.""" result = State.core.call_tool( "update_config", path=path, key=key, value=value, ) output_json(result) -
dashboard.py 5.3 KB
"""Dashboard-like information command group.""" import os import shlex import subprocess import sys from typing import Optional import typer from . import State from ..output import output_json, output_error app = typer.Typer(help="Dashboard-style configuration overview") def _resolve_windows_browser_command() -> Optional[list[str]]: """Resolve default HTTP browser command from Windows registry.""" try: import winreg except Exception: return None try: with winreg.OpenKey( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", ) as key: prog_id, _ = winreg.QueryValueEx(key, "ProgId") with winreg.OpenKey( winreg.HKEY_CLASSES_ROOT, rf"{prog_id}\shell\open\command", ) as key: command, _ = winreg.QueryValueEx(key, None) tokens = shlex.split(command, posix=False) cleaned: list[str] = [] for token in tokens: token_lower = token.lower() if token_lower in {"%1", "%l", "%*"}: continue if "%1" in token_lower or "%l" in token_lower: continue cleaned.append(token) return cleaned or None except Exception: return None def _open_browser_url(url: str, browser_cmd: Optional[str] = None) -> str: """Open URL in browser using the most reliable method for each platform.""" if sys.platform == "win32": if browser_cmd: cmd = shlex.split(browser_cmd, posix=False) subprocess.Popen([*cmd, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return "explicit-browser-cmd" resolved = _resolve_windows_browser_command() if resolved: subprocess.Popen([*resolved, url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return "windows-registry-command" os.startfile(url) return "os.startfile-fallback" if sys.platform == "darwin": subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return "open" subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return "xdg-open" @app.command("info") def info(): """Show current configuration and discoverable options.""" output_json({"result": State.core.get_dashboard_info()}) @app.command("tools") def tools(): """Show active and available tools.""" active_tools = State.core.list_tools(scope="active") available_tools = State.core.list_tools(scope="all") output_json( { "result": { "active_count": len(active_tools), "active": active_tools, "available_count": len(available_tools), "available": available_tools, } } ) @app.command("modes") def modes(): """Show active and available modes.""" active_modes = State.core.list_modes(scope="active") available_modes = State.core.list_modes(scope="all") output_json( {"result": {"active": active_modes, "available": available_modes}} ) @app.command("contexts") def contexts(): """Show active and available contexts.""" active_contexts = State.core.list_contexts(scope="active") available_contexts = State.core.list_contexts(scope="all") output_json( {"result": {"active": active_contexts, "available": available_contexts}} ) @app.command("serve") def serve( host: str = typer.Option( "127.0.0.1", "--host", help="Dashboard listen address", ), port: int = typer.Option( 0, "--port", "-p", help="Dashboard listen port (0 means auto-select)", ), open_browser: bool = typer.Option( False, "--open-browser/--no-open-browser", help="Open dashboard URL in browser on startup", ), browser_cmd: Optional[str] = typer.Option( None, "--browser-cmd", envvar="SERENA_BROWSER_CMD", help="Launch browser via explicit command, e.g. 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'", ), ): """Serve Serena web dashboard.""" if State.core is None: output_error("INIT_FAILED", "SerenaCore is not initialized") from ..server.dashboard_server import DashboardServer dashboard_server = DashboardServer(core=State.core) thread, actual_port = dashboard_server.run_in_thread( host=host, port=port if port > 0 else None ) url = dashboard_server.get_dashboard_url(host=host, port=actual_port) if open_browser: try: _open_browser_url(url, browser_cmd=browser_cmd) except Exception as e: print(f"[WARNING] Failed to open browser: {e}", file=sys.stderr) else: # Display URL for manual opening print(f"\n{'='*70}", file=sys.stderr) print(f" Serena Dashboard is running!", file=sys.stderr) print(f" URL: {url}", file=sys.stderr) print(f" Copy and paste this URL into your browser", file=sys.stderr) print(f" Or use --open-browser flag to open automatically", file=sys.stderr) print(f"{'='*70}\n", file=sys.stderr) output_json({"result": {"status": "running", "host": host, "port": actual_port, "url": url}}) while thread.is_alive(): thread.join(timeout=0.5) -
file.py 1.4 KB
"""File operations command group.""" import typer from typing import Optional from . import State from ..output import output_json app = typer.Typer(help="File system operations (list, find, search)") @app.command("list") def list_dir( path: Optional[str] = typer.Option(None, "--path", "-p", help="Directory path (default: project root)"), recursive: bool = typer.Option(False, "--recursive", "-r", help="List recursively"), ): """List directory contents.""" result = State.core.call_tool( "list_dir", relative_path=path if path is not None else ".", recursive=recursive, ) output_json(result) @app.command("find") def find_file( pattern: str = typer.Argument(..., help="File pattern (glob syntax, e.g., '**/*.py')"), ): """Find files by pattern.""" result = State.core.call_tool( "find_file", file_mask=pattern, relative_path=".", ) output_json(result) @app.command("search") def search_pattern( pattern: str = typer.Argument(..., help="Search pattern (regex)"), path: Optional[str] = typer.Option(None, "--path", "-p", help="Restrict search to file/directory"), ): """Search for pattern in files.""" result = State.core.call_tool( "search_for_pattern", substring_pattern=pattern, relative_path=path if path is not None else ".", ) output_json(result) -
memory.py 1.5 KB
"""Memory operations command group.""" import typer from . import State from ..output import output_json app = typer.Typer(help="Project memory operations (persistent knowledge)") @app.command("list") def list_memories(): """List all project memories.""" result = State.core.call_tool("list_memories") output_json(result) @app.command("read") def read_memory( name: str = typer.Argument(..., help="Memory name"), ): """Read memory content.""" result = State.core.call_tool( "read_memory", memory_file_name=name, ) output_json(result) @app.command("write") def write_memory( name: str = typer.Argument(..., help="Memory name"), content: str = typer.Option(..., "--content", "-c", help="Memory content"), ): """Create or update memory.""" result = State.core.call_tool( "write_memory", memory_name=name, content=content, ) output_json(result) @app.command("edit") def edit_memory( name: str = typer.Argument(..., help="Memory name"), content: str = typer.Option(..., "--content", "-c", help="New memory content"), ): """Edit existing memory.""" result = State.core.call_tool( "write_memory", memory_name=name, content=content, ) output_json(result) @app.command("delete") def delete_memory( name: str = typer.Argument(..., help="Memory name"), ): """Delete a memory.""" result = State.core.call_tool( "delete_memory", memory_file_name=name, ) output_json(result) -
symbol.py 3.7 KB
"""Symbol operations command group.""" import typer from typing import Optional from . import State from ..output import output_json app = typer.Typer(help="Symbol operations (find, overview, references, edit)") @app.command("find") def find_symbol( name_path: str = typer.Argument(..., help="Symbol name or path pattern"), path: Optional[str] = typer.Option(None, "--path", "-p", help="Restrict search to file/directory"), body: bool = typer.Option(False, "--body", "-b", help="Include symbol body in output"), depth: int = typer.Option(0, "--depth", "-d", help="Depth for nested symbols (0=current level only)"), exact: bool = typer.Option(False, "--exact", "-e", help="Exact match (disable substring matching)"), ): """Find symbols by name or path pattern.""" result = State.core.call_tool( "find_symbol", name_path=name_path, relative_path=path, include_body=body, depth=depth, substring_matching=not exact, ) output_json(result) @app.command("overview") def symbols_overview( path: str = typer.Argument(..., help="File path to analyze"), ): """List all symbols in a file.""" result = State.core.call_tool( "get_symbols_overview", relative_path=path, ) output_json(result) @app.command("refs") def find_refs( name_path: str = typer.Argument(..., help="Symbol name or path"), path: Optional[str] = typer.Option(None, "--path", "-p", help="Restrict search to file/directory"), snippets: bool = typer.Option(True, "--snippets/--no-snippets", help="Include code snippets"), ): """Find references to a symbol.""" result = State.core.call_tool( "find_referencing_symbols", name_path=name_path, relative_path=path, include_code_snippets=snippets, ) output_json(result) @app.command("replace") def replace_symbol( name_path: str = typer.Argument(..., help="Symbol name or path"), path: str = typer.Option(..., "--path", "-p", help="File containing the symbol"), body: str = typer.Option(..., "--body", "-b", help="New symbol body"), ): """Replace symbol body.""" result = State.core.call_tool( "replace_symbol_body", name_path=name_path, relative_path=path, new_body=body, ) output_json(result) @app.command("insert-after") def insert_after( name_path: str = typer.Argument(..., help="Symbol name or path"), path: str = typer.Option(..., "--path", "-p", help="File containing the symbol"), content: str = typer.Option(..., "--content", "-c", help="Content to insert"), ): """Insert content after a symbol.""" result = State.core.call_tool( "insert_after_symbol", name_path=name_path, relative_path=path, content=content, ) output_json(result) @app.command("insert-before") def insert_before( name_path: str = typer.Argument(..., help="Symbol name or path"), path: str = typer.Option(..., "--path", "-p", help="File containing the symbol"), content: str = typer.Option(..., "--content", "-c", help="Content to insert"), ): """Insert content before a symbol.""" result = State.core.call_tool( "insert_before_symbol", name_path=name_path, relative_path=path, content=content, ) output_json(result) @app.command("rename") def rename_symbol( name_path: str = typer.Argument(..., help="Symbol name or path"), new_name: str = typer.Argument(..., help="New symbol name"), path: str = typer.Option(..., "--path", "-p", help="File containing the symbol"), ): """Rename a symbol.""" result = State.core.call_tool( "rename_symbol", name_path=name_path, relative_path=path, new_name=new_name, ) output_json(result) -
workflow.py 780 B
"""Workflow operations command group.""" import typer from . import State from ..output import output_json app = typer.Typer(help="Workflow operations (onboarding, diagnostics)") @app.command("onboarding") def onboarding(): """Run project onboarding process.""" result = State.core.call_tool("onboarding") output_json(result) @app.command("check") def check_onboarding(): """Check if onboarding has been performed.""" result = State.core.call_tool("check_onboarding_performed") output_json(result) @app.command("tools") def list_tools( scope: str = typer.Option("active", "--scope", "-s", help="Tool scope: 'active' or 'all'"), ): """List available tools.""" tools = State.core.list_tools(scope=scope) output_json({"result": tools}) -
__init__.py 3.4 KB
"""CLI package for Serena tools using Typer.""" import os import typer from typing import Optional, List from pathlib import Path # Load .env file if it exists def _load_dotenv(): """Load environment variables from .env file.""" try: from dotenv import load_dotenv # Try multiple locations env_paths = [ Path(__file__).parent.parent / ".env", # skills/serena/tools/.env Path(__file__).parent.parent.parent / ".env", # skills/serena/.env Path.cwd() / ".env", # current directory ] for env_path in env_paths: if env_path.exists(): load_dotenv(env_path, override=False) return except ImportError: # python-dotenv not installed, skip pass _load_dotenv() app = typer.Typer( name="serena", help="Semantic code operations with IDE-like symbol navigation", no_args_is_help=True, ) class State: """Global state for sharing SerenaCore instance across commands.""" core: Optional["SerenaCore"] = None @app.callback() def main( ctx: typer.Context, project: str = typer.Option( ".", "-p", "--project", envvar="SERENA_PROJECT", help="Project directory path", ), context: Optional[str] = typer.Option( None, "-c", "--context", envvar="SERENA_CONTEXT", help="Execution context (agent, claude-code, ide, codex). Auto-detected if not specified.", ), mode: Optional[List[str]] = typer.Option( None, "-m", "--mode", help="Operation modes (can be specified multiple times)", ), ): """ Initialize SerenaCore with global options. All commands inherit these global options and share the same SerenaCore instance. """ from ..core import SerenaCore from ..extended.cmd_tools import RunCommandTool, RunScriptTool from ..extended.config_tools import ReadConfigTool, UpdateConfigTool from ..output import output_error try: # Initialize SerenaCore with global options # Read modes from environment variable if not provided via CLI if mode is None: import os env_modes = os.environ.get("SERENA_MODES") if env_modes: modes = [m.strip() for m in env_modes.split(",")] else: modes = ["interactive", "editing"] else: modes = mode State.core = SerenaCore(project=project, context=context, modes=modes) # Register extended tools State.core.register_tool(RunCommandTool()) State.core.register_tool(RunScriptTool()) State.core.register_tool(ReadConfigTool()) State.core.register_tool(UpdateConfigTool()) except Exception as e: output_error("INIT_FAILED", str(e)) # Command groups will be registered here from .symbol import app as symbol_app from .memory import app as memory_app from .file import app as file_app from .workflow import app as workflow_app from .cmd import app as cmd_app from .config import app as config_app from .dashboard import app as dashboard_app app.add_typer(symbol_app, name="symbol") app.add_typer(memory_app, name="memory") app.add_typer(file_app, name="file") app.add_typer(workflow_app, name="workflow") app.add_typer(cmd_app, name="cmd") app.add_typer(config_app, name="config") app.add_typer(dashboard_app, name="dashboard")
-
-
extended
-
cmd_tools.py 3.7 KB
"""Command execution tools with structured output.""" from typing import Optional, Type, Dict, Any import subprocess import shlex import json from pathlib import Path from pydantic import BaseModel, Field try: from serena.tools.base import BaseTool except ImportError: class BaseTool: pass class RunCommandInput(BaseModel): command: str = Field(..., description="The shell command to execute") cwd: Optional[str] = Field(None, description="Working directory for execution") timeout: Optional[int] = Field(300, description="Timeout in seconds") class RunCommandTool(BaseTool): name = "run_command" description = "Execute a shell command on the system. Use with caution." args_schema: Type[BaseModel] = RunCommandInput def _execute(self, command: str, cwd: Optional[str], timeout: int) -> Dict[str, Any]: """Internal method returning structured data.""" try: process = subprocess.run( command, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout ) return { "exit_code": process.returncode, "stdout": process.stdout, "stderr": process.stderr, } except subprocess.TimeoutExpired: return { "error": { "code": "TIMEOUT", "message": f"Command timed out after {timeout} seconds" } } except Exception as e: return { "error": { "code": "RUNTIME_ERROR", "message": str(e) } } def _run(self, command: str, cwd: Optional[str] = None, timeout: int = 300) -> str: """BaseTool-compatible method returning JSON string.""" result = self._execute(command, cwd, timeout) return json.dumps(result, ensure_ascii=False) class RunScriptInput(BaseModel): script_path: str = Field(..., description="Path to the script file") args: Optional[str] = Field(None, description="Arguments for the script") class RunScriptTool(BaseTool): name = "run_script" description = "Execute a local script file (python, bash, etc.)" args_schema: Type[BaseModel] = RunScriptInput def _execute(self, script_path: str, args: Optional[str]) -> Dict[str, Any]: """Internal method returning structured data.""" import sys path = Path(script_path) if not path.exists(): return { "error": { "code": "FILE_NOT_FOUND", "message": f"Script not found at {script_path}" } } cmd = [] if path.suffix == '.py': cmd = [sys.executable, str(path)] elif path.suffix == '.sh': cmd = ['bash', str(path)] else: cmd = [str(path)] if args: cmd.extend(shlex.split(args)) try: process = subprocess.run( cmd, capture_output=True, text=True ) return { "exit_code": process.returncode, "stdout": process.stdout, "stderr": process.stderr, } except Exception as e: return { "error": { "code": "RUNTIME_ERROR", "message": str(e) } } def _run(self, script_path: str, args: Optional[str] = None) -> str: """BaseTool-compatible method returning JSON string.""" result = self._execute(script_path, args) return json.dumps(result, ensure_ascii=False) -
config_tools.py 4.9 KB
"""Configuration file tools with structured output.""" from typing import Optional, Type, Dict, Any import json import yaml from pathlib import Path from pydantic import BaseModel, Field try: from serena.tools.base import BaseTool except ImportError: class BaseTool: pass class ReadConfigInput(BaseModel): path: str = Field(..., description="Path to configuration file") format: Optional[str] = Field(None, description="Format (json, yaml). Auto-detected if None") class ReadConfigTool(BaseTool): name = "read_config" description = "Read a configuration file (JSON/YAML) into a dictionary" args_schema: Type[BaseModel] = ReadConfigInput def _execute(self, path: str, format: Optional[str]) -> Dict[str, Any]: """Internal method returning structured data.""" file_path = Path(path) if not file_path.exists(): return { "error": { "code": "FILE_NOT_FOUND", "message": f"File not found at {path}" } } try: content = file_path.read_text(encoding='utf-8') fmt = format.lower() if format else file_path.suffix.lstrip('.') if fmt in ['json']: data = json.loads(content) elif fmt in ['yaml', 'yml']: data = yaml.safe_load(content) else: return { "error": { "code": "UNSUPPORTED_FORMAT", "message": f"Unsupported format {fmt}. Use json or yaml." } } return {"config": data} except Exception as e: return { "error": { "code": "PARSE_ERROR", "message": str(e) } } def _run(self, path: str, format: Optional[str] = None) -> str: """BaseTool-compatible method returning JSON string.""" result = self._execute(path, format) return json.dumps(result, indent=2, ensure_ascii=False) class UpdateConfigInput(BaseModel): path: str = Field(..., description="Path to configuration file") key: str = Field(..., description="Dot-notation key to update (e.g. 'server.port')") value: str = Field(..., description="New value (parsed as JSON if possible)") class UpdateConfigTool(BaseTool): name = "update_config" description = "Update a specific key in a configuration file" args_schema: Type[BaseModel] = UpdateConfigInput def _set_nested(self, data: Dict, key: str, value: Any): """Set nested dictionary value using dot notation.""" parts = key.split('.') current = data for part in parts[:-1]: if part not in current: current[part] = {} current = current[part] if not isinstance(current, dict): raise ValueError(f"Cannot traverse path {key}: {part} is not a dict") current[parts[-1]] = value def _parse_value(self, value_str: str) -> Any: """Parse value string as JSON or return as string.""" try: return json.loads(value_str) except: return value_str def _execute(self, path: str, key: str, value: str) -> Dict[str, Any]: """Internal method returning structured data.""" file_path = Path(path) if not file_path.exists(): return { "error": { "code": "FILE_NOT_FOUND", "message": f"File not found at {path}" } } try: content = file_path.read_text(encoding='utf-8') fmt = file_path.suffix.lstrip('.').lower() if fmt == 'json': data = json.loads(content) elif fmt in ['yaml', 'yml']: data = yaml.safe_load(content) or {} else: return { "error": { "code": "UNSUPPORTED_FORMAT", "message": f"Unsupported format {fmt}" } } parsed_value = self._parse_value(value) self._set_nested(data, key, parsed_value) if fmt == 'json': new_content = json.dumps(data, indent=2, ensure_ascii=False) else: new_content = yaml.safe_dump(data, allow_unicode=True) file_path.write_text(new_content, encoding='utf-8') return {"message": "Config updated successfully", "key": key, "value": parsed_value} except Exception as e: return { "error": { "code": "UPDATE_ERROR", "message": str(e) } } def _run(self, path: str, key: str, value: str) -> str: """BaseTool-compatible method returning JSON string.""" result = self._execute(path, key, value) return json.dumps(result, ensure_ascii=False) -
__init__.py 30 B
"""Extended tools package."""
-
-
server
-
dashboard_server.py 14.1 KB
"""Lightweight Flask dashboard server for Serena CLI wrapper.""" import os import socket import threading from pathlib import Path from typing import Any, Optional from flask import Flask, Response, redirect, request, send_from_directory from ..core import SerenaCore class DashboardServer: """Flask-based dashboard HTTP server.""" DEFAULT_START_PORT = 0x5EDA UPSTREAM_DASHBOARD_DIR = Path( os.environ.get( "SERENA_UPSTREAM_DASHBOARD_DIR", "E:/Python/PythonProject1/serena/src/serena/resources/dashboard", ) ) def __init__(self, core: SerenaCore, dashboard_dir: Optional[Path | str] = None): self._core = core self._dashboard_dir = self._resolve_dashboard_dir(dashboard_dir) self._app = Flask(__name__) self._setup_routes() # Register current project in global Serena config self._update_global_serena_config() @property def app(self) -> Flask: return self._app @staticmethod def _normalize_names(value: Any) -> list[str]: if value is None: return [] if isinstance(value, list): normalized = [str(item) for item in value if item is not None and str(item)] return sorted(set(normalized)) value_str = str(value) return [value_str] if value_str else [] @staticmethod def _build_active_project_name(project_path: Optional[str]) -> Optional[str]: if not project_path: return None path = Path(project_path) return path.name or project_path def _resolve_dashboard_dir(self, dashboard_dir: Optional[Path | str]) -> Path: candidates: list[Path] = [] if dashboard_dir is not None: candidates.append(Path(dashboard_dir)) candidates.append(Path(__file__).resolve().parents[1] / "resources" / "dashboard") candidates.append(self.UPSTREAM_DASHBOARD_DIR) for candidate in candidates: if (candidate / "index.html").exists(): return candidate searched = ", ".join(str(path) for path in candidates) raise FileNotFoundError(f"Serena dashboard static files not found. searched=[{searched}]") def _update_global_serena_config(self) -> None: """Update global Serena config to register the current project.""" try: import yaml from pathlib import Path # Get current project path dashboard_info = self._core.get_dashboard_info() project_path = dashboard_info.get("active_project_path") if not project_path: return project_path = str(Path(project_path).resolve()) # Load global config global_config_path = Path.home() / ".serena" / "serena_config.yml" if not global_config_path.exists(): return with open(global_config_path, 'r', encoding='utf-8') as f: config = yaml.safe_load(f) or {} # Add project to projects list if not already present projects = config.get("projects", []) if project_path not in projects: projects.append(project_path) config["projects"] = projects # Save updated config with open(global_config_path, 'w', encoding='utf-8') as f: yaml.dump(config, f, default_flow_style=False, allow_unicode=True) except Exception as e: # Don't fail if global config update fails import sys print(f"[WARNING] Failed to update global Serena config: {e}", file=sys.stderr) def _build_config_overview(self) -> dict[str, Any]: dashboard_info = self._core.get_dashboard_info() project_path_raw = dashboard_info.get("active_project_path") project_path = str(project_path_raw) if project_path_raw else None project_name = self._build_active_project_name(project_path) context_name = str(dashboard_info.get("context") or "agent") active_tools = self._normalize_names(dashboard_info.get("active_tools")) available_tools = self._normalize_names(dashboard_info.get("available_tools")) if not available_tools: available_tools = list(active_tools) active_modes = self._normalize_names(dashboard_info.get("active_modes")) available_modes = self._normalize_names(dashboard_info.get("available_modes")) available_modes = sorted(set(available_modes + active_modes)) available_contexts = self._normalize_names(dashboard_info.get("available_contexts")) available_contexts = sorted(set(available_contexts + [context_name])) active_tool_set = set(active_tools) active_mode_set = set(active_modes) registered_projects: list[dict[str, str | bool]] = [] if project_path: registered_projects.append( { "name": project_name or project_path, "path": project_path, "is_active": True, } ) return { "active_project": { "name": project_name, "language": None, "path": project_path, }, "context": { "name": context_name, "description": "", "path": "", }, "modes": [ { "name": mode_name, "description": "", "path": "", } for mode_name in active_modes ], "active_tools": active_tools, "tool_stats_summary": { tool_name: {"num_calls": 0} for tool_name in active_tools }, "registered_projects": registered_projects, "available_tools": [ {"name": tool_name, "is_active": False} for tool_name in available_tools if tool_name not in active_tool_set ], "available_modes": [ { "name": mode_name, "is_active": mode_name in active_mode_set, "path": "", } for mode_name in available_modes ], "available_contexts": [ { "name": context_item, "is_active": context_item == context_name, "path": "", } for context_item in available_contexts ], "available_memories": None, "jetbrains_mode": False, "languages": [], "encoding": None, "current_client": None, } def _setup_routes(self) -> None: @self._app.route("/") def root() -> Response: return redirect("/dashboard/index.html", code=302) @self._app.route("/dashboard") def dashboard_root() -> Response: return redirect("/dashboard/index.html", code=302) @self._app.route("/dashboard/") def dashboard_index_redirect() -> Response: return redirect("/dashboard/index.html", code=302) @self._app.route("/dashboard/index.html") def dashboard_index() -> Response: return send_from_directory(str(self._dashboard_dir), "index.html") @self._app.route("/dashboard/<path:filename>") def dashboard_files(filename: str) -> Response: return send_from_directory(str(self._dashboard_dir), filename) @self._app.route("/heartbeat", methods=["GET"]) def heartbeat() -> dict[str, str]: return {"status": "alive"} @self._app.route("/get_config_overview", methods=["GET"]) def get_config_overview() -> dict[str, Any]: return self._build_config_overview() @self._app.route("/get_tool_names", methods=["GET"]) def get_tool_names() -> dict[str, list[str]]: return {"tool_names": self._normalize_names(self._core.list_tools(scope="active"))} @self._app.route("/get_log_messages", methods=["POST"]) def get_log_messages() -> dict[str, Any]: request_data = request.get_json(silent=True) or {} if not isinstance(request_data, dict): request_data = {} try: start_idx = int(request_data.get("start_idx", 0)) except (TypeError, ValueError): start_idx = 0 dashboard_info = self._core.get_dashboard_info() project_path = dashboard_info.get("active_project_path") active_project = self._build_active_project_name(str(project_path)) if project_path else None return { "messages": [], "max_idx": start_idx - 1, "active_project": active_project, } @self._app.route("/queued_task_executions", methods=["GET"]) def queued_task_executions() -> dict[str, list]: """Return empty executions queue (CLI wrapper doesn't track executions).""" return {"queued_executions": []} @self._app.route("/last_execution", methods=["GET"]) def last_execution() -> dict[str, Any]: """Return empty last execution (CLI wrapper doesn't track executions).""" return { "task_id": None, "is_running": False, "name": None, "finished_successfully": False, "logged": False, } @self._app.route("/get_serena_config", methods=["GET"]) def get_serena_config() -> dict[str, Any]: """Return Serena configuration from .env file.""" from pathlib import Path import os # Try to find .env file env_paths = [ Path(__file__).resolve().parents[2] / ".env", # skills/serena/.env Path.cwd() / ".env", ] config_content = "" config_path = None for env_path in env_paths: if env_path.exists(): config_path = str(env_path) try: with open(env_path, 'r', encoding='utf-8') as f: config_content = f.read() break except Exception: pass if not config_content: # Return default template if no .env found config_content = """# Serena CLI Configuration SERENA_CONTEXT=claude-code SERENA_MODES=interactive,editing,onboarding SERENA_PROJECT=. SERENA_DASHBOARD_ENABLED=true SERENA_DASHBOARD_PORT=24282 """ config_path = str(env_paths[0]) return { "status": "success", "content": config_content, "config": config_content, # Backward compatibility "path": config_path, } @self._app.route("/save_serena_config", methods=["POST"]) @self._app.route("/update_serena_config", methods=["POST"]) def update_serena_config() -> dict[str, Any]: """Update Serena configuration in .env file.""" from pathlib import Path request_data = request.get_json(silent=True) or {} # Support both 'content' (frontend) and 'config' (backward compatibility) new_config = request_data.get("content") if new_config is None: new_config = request_data.get("config", "") if not new_config: return { "status": "error", "message": "No configuration provided", "success": False, } # Try to find .env file env_paths = [ Path(__file__).resolve().parents[2] / ".env", Path.cwd() / ".env", ] config_path = None for env_path in env_paths: if env_path.exists(): config_path = env_path break if not config_path: # Create new .env file config_path = env_paths[0] try: config_path.parent.mkdir(parents=True, exist_ok=True) with open(config_path, 'w', encoding='utf-8') as f: f.write(new_config) return { "status": "success", "message": "Serena config saved successfully", "path": str(config_path), "success": True, } except Exception as e: return { "status": "error", "message": str(e), "success": False, } @staticmethod def _find_first_free_port(start_port: int, host: str) -> int: port = max(0, start_port) while port <= 65535: try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind((host, port)) return port except OSError: port += 1 raise RuntimeError(f"No free ports found starting from {start_port}") @staticmethod def get_dashboard_url(host: str, port: int) -> str: browser_host = "localhost" if host in {"0.0.0.0", "::"} else host # Use canonical URL to avoid equivalent-path duplication in browser tabs return f"http://{browser_host}:{port}/dashboard/index.html" def run(self, host: str, port: int) -> int: from flask import cli cli.show_server_banner = lambda *args, **kwargs: None self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) return port def run_in_thread(self, host: str = "127.0.0.1", port: Optional[int] = None) -> tuple[threading.Thread, int]: resolved_port = port if port is not None and port > 0 else self._find_first_free_port(self.DEFAULT_START_PORT, host) thread = threading.Thread(target=lambda: self.run(host=host, port=resolved_port), daemon=True) thread.start() return thread, resolved_port -
__init__.py 110 B
"""Dashboard server package.""" from .dashboard_server import DashboardServer __all__ = ["DashboardServer"]
-
-
tests
-
conftest.py 256 B
"""Pytest configuration for Serena tools tests.""" import sys from pathlib import Path # Add skills/serena directory to Python path so tools can be imported as a package serena_dir = Path(__file__).parent.parent.parent sys.path.insert(0, str(serena_dir)) -
test_cli_dashboard.py 3 KB
"""Tests for dashboard CLI commands.""" def test_dashboard_info_outputs_result_wrapper(): """Test that dashboard info wraps output under result key.""" from unittest.mock import Mock, patch with patch('tools.cli.dashboard.output_json') as mock_output: from tools.cli import State from tools.cli.dashboard import info mock_core = Mock() State.core = mock_core mock_core.get_dashboard_info.return_value = {"context": "agent"} info() mock_core.get_dashboard_info.assert_called_once_with() mock_output.assert_called_once_with({"result": {"context": "agent"}}) def test_dashboard_tools_outputs_active_and_available(): """Test dashboard tools command output structure.""" from unittest.mock import Mock, patch, call with patch('tools.cli.dashboard.output_json') as mock_output: from tools.cli import State from tools.cli.dashboard import tools mock_core = Mock() State.core = mock_core mock_core.list_tools.side_effect = [["read_memory"], ["read_memory", "find_symbol"]] tools() mock_core.list_tools.assert_has_calls([call(scope="active"), call(scope="all")]) mock_output.assert_called_once_with( { "result": { "active_count": 1, "active": ["read_memory"], "available_count": 2, "available": ["read_memory", "find_symbol"], } } ) def test_dashboard_modes_outputs_active_and_available(): """Test dashboard modes command output structure.""" from unittest.mock import Mock, patch, call with patch('tools.cli.dashboard.output_json') as mock_output: from tools.cli import State from tools.cli.dashboard import modes mock_core = Mock() State.core = mock_core mock_core.list_modes.side_effect = [["interactive"], ["interactive", "editing"]] modes() mock_core.list_modes.assert_has_calls([call(scope="active"), call(scope="all")]) mock_output.assert_called_once_with( {"result": {"active": ["interactive"], "available": ["interactive", "editing"]}} ) def test_dashboard_contexts_outputs_active_and_available(): """Test dashboard contexts command output structure.""" from unittest.mock import Mock, patch, call with patch('tools.cli.dashboard.output_json') as mock_output: from tools.cli import State from tools.cli.dashboard import contexts mock_core = Mock() State.core = mock_core mock_core.list_contexts.side_effect = [["codex"], ["agent", "claude-code", "codex", "ide"]] contexts() mock_core.list_contexts.assert_has_calls([call(scope="active"), call(scope="all")]) mock_output.assert_called_once_with( { "result": { "active": ["codex"], "available": ["agent", "claude-code", "codex", "ide"], } } ) -
test_cli_file.py 3.4 KB
"""Tests for file CLI commands parameter mapping.""" def test_search_pattern_parameter_mapping(): """Test that search_pattern function calls tool with correct parameters.""" from unittest.mock import Mock, patch # Mock output_json to avoid side effects with patch('tools.output.output_json'): from tools.cli import State from tools.cli.file import search_pattern # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"results": []} # Execute search_pattern("test_pattern", path=None) # Verify - should use substring_pattern, not pattern mock_core.call_tool.assert_called_once_with( "search_for_pattern", substring_pattern="test_pattern", relative_path=None, ) def test_search_pattern_with_path(): """Test search with path restriction.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.file import search_pattern # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"results": []} # Execute search_pattern("test_pattern", path="src") # Verify mock_core.call_tool.assert_called_once_with( "search_for_pattern", substring_pattern="test_pattern", relative_path="src", ) def test_find_file_parameter_mapping(): """Test that find_file function calls tool with correct parameters.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.file import find_file # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"files": []} # Execute find_file("*.py") # Verify - should use file_mask and relative_path="." mock_core.call_tool.assert_called_once_with( "find_file", file_mask="*.py", relative_path=".", ) def test_list_dir_default_path(): """Test that list_dir defaults to relative_path='.' when path is None.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.file import list_dir # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"entries": []} # Execute without path list_dir(path=None, recursive=False) # Verify - should default to relative_path="." mock_core.call_tool.assert_called_once_with( "list_dir", relative_path=".", recursive=False, ) def test_list_dir_with_path(): """Test list_dir with explicit path.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.file import list_dir # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"entries": []} # Execute with path list_dir(path="src", recursive=True) # Verify mock_core.call_tool.assert_called_once_with( "list_dir", relative_path="src", recursive=True, ) -
test_cli_memory.py 2.6 KB
"""Tests for memory CLI commands parameter mapping.""" def test_read_memory_parameter_mapping(): """Test that read_memory maps name to memory_file_name.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.memory import read_memory # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"content": "test"} # Execute read_memory("project_notes") # Verify mock_core.call_tool.assert_called_once_with( "read_memory", memory_file_name="project_notes", ) def test_write_memory_parameter_mapping(): """Test that write_memory maps name to memory_name.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.memory import write_memory # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"success": True} # Execute write_memory("api_notes", content="test content") # Verify mock_core.call_tool.assert_called_once_with( "write_memory", memory_name="api_notes", content="test content", ) def test_edit_memory_parameter_mapping(): """Test that edit_memory maps to write_memory with memory_name.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.memory import edit_memory # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"success": True} # Execute edit_memory("notes", content="new content") # Verify - edit should use write_memory tool mock_core.call_tool.assert_called_once_with( "write_memory", memory_name="notes", content="new content", ) def test_delete_memory_parameter_mapping(): """Test that delete_memory maps name to memory_file_name.""" from unittest.mock import Mock, patch with patch('tools.output.output_json'): from tools.cli import State from tools.cli.memory import delete_memory # Setup mock mock_core = Mock() State.core = mock_core mock_core.call_tool.return_value = {"success": True} # Execute delete_memory("old_notes") # Verify mock_core.call_tool.assert_called_once_with( "delete_memory", memory_file_name="old_notes", ) -
test_core.py 5.4 KB
"""Tests for SerenaCore context detection.""" import os from pathlib import Path from unittest.mock import patch import tempfile import shutil def test_context_detection_claude_code_directory(): """Test Claude Code detection via .claude directory.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) (tmppath / ".claude").mkdir() core = SerenaCore(project=str(tmppath)) assert core.context == "claude-code" def test_context_detection_claude_code_env(): """Test Claude Code detection via CLAUDECODE environment variable.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"CLAUDECODE": "1"}): core = SerenaCore(project=tmpdir) assert core.context == "claude-code" def test_context_detection_vscode_directory(): """Test VSCode detection via .vscode directory.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) (tmppath / ".vscode").mkdir() core = SerenaCore(project=str(tmppath)) assert core.context == "ide" def test_context_detection_vscode_env(): """Test VSCode detection via VSCODE_PID environment variable.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"VSCODE_PID": "12345"}): core = SerenaCore(project=tmpdir) assert core.context == "ide" def test_context_detection_jetbrains_directory(): """Test JetBrains detection via .idea directory.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) (tmppath / ".idea").mkdir() core = SerenaCore(project=str(tmppath)) assert core.context == "ide" def test_context_detection_jetbrains_env(): """Test JetBrains detection via IDEA_INITIAL_DIRECTORY environment variable.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"IDEA_INITIAL_DIRECTORY": tmpdir}): core = SerenaCore(project=tmpdir) assert core.context == "ide" def test_context_detection_codex_directory(): """Test Codex detection via .codex directory.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) (tmppath / ".codex").mkdir() core = SerenaCore(project=str(tmppath)) assert core.context == "codex" def test_context_detection_codex_env(): """Test Codex detection via CODEX_CLI_SESSION environment variable.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"CODEX_CLI_SESSION": "test-session"}): core = SerenaCore(project=tmpdir) assert core.context == "codex" def test_context_detection_default(): """Test default context when no markers exist.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: # Clean environment - remove keys if they exist env_backup = {} keys_to_remove = ["CLAUDE_CODE_SESSION", "VSCODE_PID", "IDEA_INITIAL_DIRECTORY", "CODEX_CLI_SESSION"] for key in keys_to_remove: if key in os.environ: env_backup[key] = os.environ.pop(key) try: core = SerenaCore(project=tmpdir) assert core.context == "agent" finally: # Restore environment os.environ.update(env_backup) def test_context_detection_priority_directory_over_env(): """Test that directory markers take precedence over environment variables.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: tmppath = Path(tmpdir) (tmppath / ".claude").mkdir() # Set conflicting environment variable with patch.dict(os.environ, {"CODEX_CLI_SESSION": "test-session"}): core = SerenaCore(project=str(tmppath)) # Should detect claude-code from directory, not codex from env assert core.context == "claude-code" def test_serena_core_initializes_config_path(): """Test that SerenaCore initializes config path on creation.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"SERENA_HOME": tmpdir}): core = SerenaCore(project=tmpdir) # Config file should be created config_file = Path(tmpdir) / "serena_config.yml" assert config_file.exists() def test_get_dashboard_info_without_agent_uses_wrapper_state(): """Test dashboard info fallback when agent is unavailable.""" from tools.core import SerenaCore with tempfile.TemporaryDirectory() as tmpdir: core = SerenaCore(project=tmpdir, context="codex", modes=["interactive", "editing"]) with patch.object(core, "_ensure_agent", side_effect=RuntimeError("agent unavailable")): info = core.get_dashboard_info() assert info["active_project_path"] == str(Path(tmpdir).resolve()) assert info["context"] == "codex" assert info["active_modes"] == ["editing", "interactive"] assert info["active_tools_count"] == 0 assert "codex" in info["available_contexts"] assert "interactive" in info["available_modes"] -
test_module_path.py 3 KB
"""Regression test for module path fix. This test ensures that the serena tools can be executed from the serena root directory using 'python -m tools' command, which is the correct usage after skill installation. Issue: Previously, SKILL.md and README.md used 'python -m skills.serena.tools' which assumed execution from the parent directory of skills/, causing ModuleNotFoundError when executed from the serena root directory. Fix: Changed all command examples to 'python -m tools' for execution from serena root. """ import subprocess import sys from pathlib import Path def test_module_execution_from_serena_root(): """Test that 'python -m tools' works from serena root directory.""" serena_root = Path(__file__).parent.parent.parent # Test basic command execution result = subprocess.run( [sys.executable, "-m", "tools", "--help"], cwd=serena_root, capture_output=True, text=True, timeout=10 ) assert result.returncode == 0, f"Command failed with: {result.stderr}" assert "serena" in result.stdout.lower(), "Help text should mention serena" def test_symbol_command_execution(): """Test that symbol commands work correctly.""" serena_root = Path(__file__).parent.parent.parent result = subprocess.run( [sys.executable, "-m", "tools", "symbol", "--help"], cwd=serena_root, capture_output=True, text=True, timeout=10 ) assert result.returncode == 0, f"Symbol command failed: {result.stderr}" assert "symbol" in result.stdout.lower() def test_dashboard_command_execution(): """Test that dashboard commands work correctly.""" serena_root = Path(__file__).parent.parent.parent result = subprocess.run( [sys.executable, "-m", "tools", "dashboard", "info"], cwd=serena_root, capture_output=True, text=True, timeout=10 ) assert result.returncode == 0, f"Dashboard command failed: {result.stderr}" # Should return JSON output assert "{" in result.stdout def test_documentation_uses_correct_module_path(): """Test that documentation files use 'python -m tools' instead of 'python -m skills.serena.tools'.""" serena_root = Path(__file__).parent.parent.parent # Check SKILL.md skill_md = (serena_root / "SKILL.md").read_text(encoding="utf-8") assert "skills.serena.tools" not in skill_md, "SKILL.md should not contain 'skills.serena.tools'" assert "python -m tools" in skill_md, "SKILL.md should contain 'python -m tools'" # Check README.md readme_md = (serena_root / "README.md").read_text(encoding="utf-8") assert "skills.serena.tools" not in readme_md, "README.md should not contain 'skills.serena.tools'" assert "python -m tools" in readme_md, "README.md should contain 'python -m tools'" if __name__ == "__main__": test_module_execution_from_serena_root() test_symbol_command_execution() test_dashboard_command_execution() test_documentation_uses_correct_module_path() print("✅ All regression tests passed!") -
test_paths.py 2.8 KB
"""Tests for path resolution module.""" import os from pathlib import Path from unittest.mock import patch import tempfile def test_get_config_dir_default(): """Test default config directory resolution.""" from tools.paths import SerenaToolsPaths paths = SerenaToolsPaths() config_dir = paths.get_config_dir() # Should resolve to ~/.serena expected = Path.home() / ".serena" assert config_dir == expected def test_get_config_dir_with_serena_home(): """Test config directory with SERENA_HOME override.""" from tools.paths import SerenaToolsPaths with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"SERENA_HOME": tmpdir}): paths = SerenaToolsPaths() config_dir = paths.get_config_dir() assert config_dir == Path(tmpdir) def test_get_config_file_path(): """Test config file path resolution.""" from tools.paths import SerenaToolsPaths paths = SerenaToolsPaths() config_file = paths.get_config_file_path() # Should resolve to ~/.serena/serena_config.yml expected = Path.home() / ".serena" / "serena_config.yml" assert config_file == expected def test_ensure_config_exists_creates_directory(): """Test that ensure_config_exists creates config directory.""" from tools.paths import SerenaToolsPaths with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"SERENA_HOME": tmpdir}): paths = SerenaToolsPaths() config_file = paths.ensure_config_exists() # Directory should be created assert config_file.parent.exists() assert config_file.parent.is_dir() def test_ensure_config_exists_creates_file(): """Test that ensure_config_exists creates config file if missing.""" from tools.paths import SerenaToolsPaths with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"SERENA_HOME": tmpdir}): paths = SerenaToolsPaths() config_file = paths.ensure_config_exists() # Config file should be created assert config_file.exists() assert config_file.is_file() def test_ensure_config_exists_preserves_existing(): """Test that ensure_config_exists preserves existing config.""" from tools.paths import SerenaToolsPaths with tempfile.TemporaryDirectory() as tmpdir: with patch.dict(os.environ, {"SERENA_HOME": tmpdir}): # Create existing config config_dir = Path(tmpdir) config_dir.mkdir(exist_ok=True) config_file = config_dir / "serena_config.yml" config_file.write_text("existing: config") paths = SerenaToolsPaths() result = paths.ensure_config_exists() # Should preserve existing content assert result.read_text() == "existing: config" -
__init__.py 30 B
"""Tests for Serena Tools."""
-
-
core.py 7.8 KB
"""Serena Core Wrapper.""" import os from pathlib import Path from typing import Any, Optional, List, Dict try: from serena.agent import SerenaAgent, SerenaConfig from serena.tools import ToolRegistry except ImportError: raise ImportError("serena-agent is not installed") from .paths import SerenaToolsPaths class SerenaCore: """Wrapper for SerenaAgent providing CLI-friendly interface and context awareness.""" KNOWN_CONTEXTS = ["agent", "claude-code", "ide", "codex"] def __init__( self, project: Optional[str] = None, context: Optional[str] = None, modes: Optional[List[str]] = None, ): self.project_path = Path(project).resolve() if project else Path.cwd() self.context = context or self._detect_context(self.project_path) self.modes = modes or ["interactive", "editing"] self._agent: Optional[SerenaAgent] = None self._extended_tools: Dict[str, Any] = {} # Initialize config path self._paths = SerenaToolsPaths() self._paths.ensure_config_exists() def _detect_context(self, path: Path) -> str: """Detect environment context from SERENA_CONTEXT env var only. No automatic detection - context must be explicitly configured. Default: desktop-app (full toolset) """ # Read from environment variable only context = os.environ.get("SERENA_CONTEXT", "desktop-app") return context def _resolve_context(self) -> Optional[Any]: """Convert string context to SerenaAgentContext if needed.""" if self.context: try: from serena.agent import SerenaAgentContext # Map common aliases to actual context names context_map = { "claude-code": "desktop-app", # Claude Code uses desktop-app context } context_name = context_map.get(self.context, self.context) return SerenaAgentContext.from_name(context_name) except Exception: return None return None def _resolve_modes(self) -> Optional[List[Any]]: """Convert string modes to SerenaAgentMode list if needed.""" if self.modes: try: from serena.agent import SerenaAgentMode return [SerenaAgentMode(m) for m in self.modes] except Exception: return None return None def _ensure_agent(self) -> SerenaAgent: if self._agent is None: config = SerenaConfig() self._agent = SerenaAgent( project=str(self.project_path), serena_config=config, context=self._resolve_context(), modes=self._resolve_modes(), ) return self._agent def register_tool(self, tool: Any): """Register an extended tool locally in the wrapper.""" tool_name = tool.name if hasattr(tool, 'name') else str(tool) self._extended_tools[tool_name] = tool def call_tool(self, name: str, **kwargs) -> Dict[str, Any]: """Execute a tool by name.""" # Priority 1: Check extended tools managed by wrapper if name in self._extended_tools: tool = self._extended_tools[name] try: if hasattr(tool, "_run"): result = tool._run(**kwargs) elif hasattr(tool, "run"): result = tool.run(**kwargs) else: return {"error": {"code": "RUNTIME_ERROR", "message": f"Tool '{name}' has no recognized execution method"}} return {"result": result} except Exception as e: return {"error": {"code": "RUNTIME_ERROR", "message": str(e)}} # Priority 2: Use ToolRegistry to find and execute core tools registry = ToolRegistry() if not registry.is_valid_tool_name(name): return {"error": {"code": "TOOL_NOT_FOUND", "message": f"Tool '{name}' not found"}} try: tool_class = registry.get_tool_class_by_name(name) agent = self._ensure_agent() tool_instance = agent.get_tool(tool_class) if hasattr(tool_instance, "apply_ex"): result = tool_instance.apply_ex(**kwargs) else: result = tool_instance.apply(**kwargs) return {"result": result} except Exception as e: return {"error": {"code": "RUNTIME_ERROR", "message": str(e)}} def list_tools(self, scope: str = "active") -> List[str]: """List available tools (Agent + Extended).""" tools = list(self._extended_tools.keys()) try: agent = self._ensure_agent() if scope == "all": registry = ToolRegistry() tools.extend(registry.get_tool_names()) else: tools.extend(agent.get_active_tool_names()) except Exception: pass return sorted(list(set(tools))) def get_active_context(self) -> str: """Get active context name from wrapper. Returns the user-configured context name (not the mapped internal name). """ return self.context def get_active_modes(self) -> List[str]: """Get active mode names from wrapper configuration. Returns the user-configured modes (not filtered by agent). """ return sorted(set(self.modes)) def list_modes(self, scope: str = "all") -> List[str]: """List modes (active or all registered when discoverable).""" active_modes = self.get_active_modes() if scope == "active": return active_modes try: from serena.agent import SerenaAgentMode all_modes = SerenaAgentMode.list_registered_mode_names() return sorted(set(active_modes + all_modes)) except Exception: return active_modes def list_contexts(self, scope: str = "all") -> List[str]: """List contexts (active or all registered when discoverable).""" active_context = self.get_active_context() if scope == "active": return [active_context] if active_context else [] discovered: List[str] = [] try: from serena.agent import SerenaAgentContext discovered = SerenaAgentContext.list_registered_context_names() except Exception: discovered = [] base_contexts = discovered if discovered else list(self.KNOWN_CONTEXTS) if active_context: base_contexts.append(active_context) return sorted(set(base_contexts)) def get_dashboard_info(self) -> Dict[str, Any]: """Get dashboard-like overview of current/available configuration.""" active_tools = self.list_tools(scope="active") available_tools = self.list_tools(scope="all") active_project_path = str(self.project_path) try: agent = self._ensure_agent() if hasattr(agent, "get_active_project"): project = agent.get_active_project() if project is not None and hasattr(project, "project_root"): active_project_path = str(Path(project.project_root).resolve()) except Exception: pass return { "active_project_path": active_project_path, "context": self.get_active_context(), "active_modes": self.get_active_modes(), "active_tools_count": len(active_tools), "active_tools": active_tools, "available_tools": available_tools, "available_modes": self.list_modes(scope="all"), "available_contexts": self.list_contexts(scope="all"), } def shutdown(self): """Cleanup resources.""" if self._agent and hasattr(self._agent, "shutdown"): self._agent.shutdown() self._agent = None -
output.py 402 B
"""JSON output utilities for CLI.""" import json import sys from typing import Any def output_json(data: Any, indent: int = 2) -> None: """Print JSON to stdout.""" print(json.dumps(data, indent=indent, ensure_ascii=False)) def output_error(code: str, message: str) -> None: """Print error JSON and exit.""" output_json({"error": {"code": code, "message": message}}) sys.exit(1) -
paths.py 1.5 KB
"""Path resolution for Serena Tools configuration.""" import os from pathlib import Path from typing import Optional class SerenaToolsPaths: """Manages configuration paths for Serena Tools wrapper.""" def __init__(self): """Initialize path resolver.""" pass def get_config_dir(self) -> Path: """Get configuration directory path. Returns ~/.serena or $SERENA_HOME if set. """ serena_home = os.environ.get("SERENA_HOME") if serena_home: return Path(serena_home) return Path.home() / ".serena" def get_config_file_path(self) -> Path: """Get configuration file path. Returns <config_dir>/serena_config.yml """ return self.get_config_dir() / "serena_config.yml" def ensure_config_exists(self) -> Path: """Ensure configuration file exists, creating it if necessary. Returns path to config file. """ config_file = self.get_config_file_path() config_dir = config_file.parent # Create directory if it doesn't exist config_dir.mkdir(parents=True, exist_ok=True) # Create default config if file doesn't exist if not config_file.exists(): default_config = """# Serena Tools Configuration # This file is auto-generated on first run projects: [] web_dashboard: enabled: true host: "127.0.0.1" port: 8765 log_level: "INFO" """ config_file.write_text(default_config) return config_file -
__init__.py 310 B
"""Serena Tools Package.""" from .core import SerenaCore from .extended.cmd_tools import RunCommandTool, RunScriptTool from .extended.config_tools import ReadConfigTool, UpdateConfigTool __all__ = [ "SerenaCore", "RunCommandTool", "RunScriptTool", "ReadConfigTool", "UpdateConfigTool", ] -
__main__.py 94 B
"""Entry point for Serena CLI.""" from .cli import app if __name__ == "__main__": app()
-
-
.env.example 619 B · in bundle
-
README.md 6.8 KB
# Serena CLI Semantic code understanding with IDE-like symbol operations and Web Dashboard. MCP-independent CLI for code navigation, editing, and project memory. ## Features - **Web Dashboard**: Real-time configuration monitoring and management - **Symbol Operations**: Find, rename, replace, insert symbols with language-aware precision - **Cross-file References**: Track symbol usages across entire codebase - **Project Memory**: Persist and retrieve project knowledge across sessions - **Extended Tools**: Shell commands, config file operations - **Cross-Drive Support**: Work with projects on any drive (Windows) or path (Unix) - **Auto Project Registration**: Automatically registers projects in global Serena config ## Installation ```bash pip install serena-agent typer pyyaml ``` ## Quick Start **First-time setup**: Launch the Web Dashboard to initialize and register the project: ```bash # Start Web Dashboard (recommended for first-time use) python -m tools dashboard serve --open-browser # Or manually open browser after starting python -m tools dashboard serve # Then open: http://127.0.0.1:24282/dashboard/index.html ``` **Configuration**: Create `.env` file in `skills/serena/` directory: ```bash SERENA_CONTEXT=claude-code SERENA_MODES=interactive,editing,onboarding SERENA_PROJECT=. SERENA_DASHBOARD_ENABLED=true SERENA_DASHBOARD_PORT=24282 ``` **Basic Usage**: ```bash # Find a symbol python -m tools symbol find MyClass --body # List symbols in file python -m tools symbol overview src/main.py # Find references python -m tools symbol refs MyClass/method # List available tools python -m tools workflow tools ``` ## Command Structure ```bash python -m tools [GLOBAL OPTIONS] <command> [COMMAND OPTIONS] ``` ### Global Options Global options must be specified **before** the command: - `-p, --project PATH` - Project directory (default: current directory, env: SERENA_PROJECT) - `-c, --context TEXT` - Execution context (auto-detected if not specified, env: SERENA_CONTEXT) - `-m, --mode TEXT` - Operation modes (can be specified multiple times, env: SERENA_MODES) ### Working with Different Projects **Important**: When working with projects in different locations (especially cross-drive on Windows), use `--project`: ```bash # Correct: Specify project path with --project python -m tools --project "E:\MyProject" file search "pattern" python -m tools --project "/home/user/project" symbol find MyClass python -m tools --project "D:\workspace\app" symbol refs MyClass # Incorrect: Don't use --path with absolute paths from different drives python -m tools file search "pattern" --path "E:\MyProject" # Will fail! ``` **Why?** The `--path` option in subcommands expects **relative paths** within the project. Always use `--project` to set the project root first. ## CLI Commands ### Dashboard Commands | Command | Description | |---------|-------------| | `dashboard serve [--open-browser] [--browser-cmd <path>]` | Start Web Dashboard server | | `dashboard info` | Show current configuration overview | | `dashboard tools` | List active and available tools | | `dashboard modes` | List active and available modes | | `dashboard contexts` | List active and available contexts | **Dashboard Options**: - `--open-browser` / `--no-open-browser`: Auto-open browser (default: False) - `--browser-cmd <path>`: Specify browser executable path - `--host <address>`: Listen address (default: 127.0.0.1) - `--port <number>`: Listen port (default: 24282, 0 for auto-select) - `SERENA_BROWSER_CMD`: Environment variable for browser command ### Symbol Operations | Command | Description | |---------|-------------| | `symbol find <name> [--body] [--depth N] [--path file]` | Find symbols by name | | `symbol overview <path>` | List all symbols in file | | `symbol refs <name> [--path file]` | Find symbol references | | `symbol replace <name> --path <file> --body <code>` | Replace symbol body | | `symbol insert-after <name> --path <file> --content <code>` | Insert after symbol | | `symbol insert-before <name> --path <file> --content <code>` | Insert before symbol | | `symbol rename <name> <new> --path <file>` | Rename symbol | ### Memory Operations | Command | Description | |---------|-------------| | `memory list` | List all memories | | `memory read <name>` | Read memory content | | `memory write <name> --content <text>` | Create/update memory | | `memory edit <name> --content <text>` | Edit memory | | `memory delete <name>` | Delete memory | ### File Operations | Command | Description | |---------|-------------| | `file list [--path <dir>] [--recursive]` | List directory contents | | `file find <pattern>` | Find files by glob pattern | | `file search <pattern> [--path <dir>]` | Search for regex pattern in files | ### Extended Tools | Command | Description | |---------|-------------| | `cmd run <cmd> [--cwd <dir>] [--timeout <N>]` | Execute shell command | | `cmd script <path> [--args "..."]` | Execute script file | | `config read <path> [--format json\|yaml]` | Read JSON/YAML config | | `config update <path> <key> <value>` | Update config value | ### Workflow | Command | Description | |---------|-------------| | `workflow onboarding` | Run project onboarding | | `workflow check` | Check onboarding status | | `workflow tools [--scope all]` | List available tools | ## Output Format All CLI output is JSON: ```json // Success {"result": <data>} // Error {"error": {"code": "ERROR_CODE", "message": "description"}} ``` **Error codes**: `INVALID_ARGS`, `TOOL_NOT_FOUND`, `INIT_FAILED`, `RUNTIME_ERROR` ## Web Dashboard Features The Web Dashboard provides: - **Real-time Configuration**: View active context, modes, and tools - **Project Management**: See registered projects and active project - **Tool Monitoring**: Track active and available tools - **Configuration Editing**: Edit `.env` file directly from browser - **Auto Registration**: Automatically adds project to `~/.serena/serena_config.yml` Access the dashboard at: `http://127.0.0.1:24282/dashboard/index.html` ## Project Structure ``` skills/serena/ ├── SKILL.md ├── README.md ├── .env.example └── tools/ ├── core.py # SerenaCore wrapper ├── paths.py # Path utilities ├── output.py # JSON output utilities ├── cli/ # Typer CLI commands │ ├── dashboard.py │ ├── symbol.py │ ├── memory.py │ ├── file.py │ ├── workflow.py │ ├── cmd.py │ └── config.py ├── server/ # Web Dashboard server │ └── dashboard_server.py # Flask HTTP server └── extended/ # Extended tools ├── cmd_tools.py └── config_tools.py ``` ## License MIT -
SKILL.md 7.1 KB
--- name: serena description: | Semantic code understanding with IDE-like symbol operations. Use when: (1) Large codebase analysis (>50 files), (2) Symbol-level operations (find, rename, refactor), (3) Cross-file reference tracking, (4) Project memory and session persistence, (5) Multi-language semantic navigation. Triggers: "find symbol", "rename function", "find references", "symbol overview", "project memory". IMPORTANT: Prioritize Serena's symbolic tools over file-based grep/read for code exploration. --- # Serena - Semantic Code Understanding IDE-like semantic code operations via CLI. Provides symbol-level code navigation, editing, and project memory. ## Prerequisites ```bash pip install serena-agent typer pyyaml ``` ## Quick Start **First-time setup**: Launch the Web Dashboard to initialize and register the project: ```bash python -m tools dashboard serve --open-browser ``` This will: - Initialize Serena configuration - Register the current project in `~/.serena/serena_config.yml` - Open the Web Dashboard for monitoring and configuration **Configuration**: Edit `.env` file in `skills/serena/` directory: ```bash SERENA_CONTEXT=claude-code SERENA_MODES=interactive,editing,onboarding SERENA_PROJECT=. ``` ## Usage ### Basic Command Structure ```bash python -m tools [GLOBAL OPTIONS] <command> [COMMAND OPTIONS] ``` **Global Options** (must be specified before the command): - `-p, --project PATH` - Project directory (default: current directory, env: SERENA_PROJECT) - `-c, --context TEXT` - Execution context (auto-detected if not specified, env: SERENA_CONTEXT) - `-m, --mode TEXT` - Operation modes (can be specified multiple times, env: SERENA_MODES) ### Working with Different Projects **Important**: When working with projects in different locations (especially cross-drive on Windows), use `--project`: ```bash # Correct: Use --project for different project locations python -m tools --project "/path/to/project" symbol find MyClass python -m tools --project "E:\MyProject" file search "pattern" # Incorrect: Don't use --path with absolute paths from different drives python -m tools file search "pattern" --path "E:\MyProject" # Will fail! ``` The `--path` option in subcommands expects **relative paths** within the project. Always use `--project` to set the project root first. ### Common Operations ```bash # Dashboard python -m tools dashboard serve --open-browser python -m tools dashboard info # Symbol operations python -m tools symbol find MyClass --body python -m tools symbol overview src/main.py python -m tools symbol refs MyClass/method python -m tools symbol rename OldName NewName --path src/file.py # Memory operations python -m tools memory list python -m tools memory read project_overview python -m tools memory write api_notes --content "..." # File operations python -m tools file list --recursive python -m tools file find "**/*.py" python -m tools file search "TODO:.*" --path src # Extended tools python -m tools cmd run "git status" python -m tools config read config.json ``` ## Tool Routing Policy ### Prefer Serena Over Built-in Tools | Task | Avoid | Use Serena CLI | |------|-------|----------------| | Find function | `grep "def func"` | `symbol find func --body` | | List file structure | `cat file.py` | `symbol overview file.py` | | Find usages | `grep "func("` | `symbol refs func` | | Edit function | `Edit` tool | `symbol replace func --path file.py` | | Rename | Manual find/replace | `symbol rename old new --path file.py` | ### When to Use Built-in Tools - Simple text search (non-code patterns) - Configuration files (JSON, YAML) - Documentation files (Markdown) ## Command Reference ### Dashboard Commands | Command | Description | |---------|-------------| | `dashboard serve [--open-browser] [--browser-cmd <path>]` | Start Web Dashboard server | | `dashboard info` | Show current configuration | | `dashboard tools` | List active and available tools | | `dashboard modes` | List active and available modes | | `dashboard contexts` | List active and available contexts | ### Symbol Commands | Command | Description | |---------|-------------| | `symbol find <name> [--body] [--depth N] [--path file]` | Find symbols by name | | `symbol overview <path>` | List all symbols in file | | `symbol refs <name> [--path file]` | Find symbol references | | `symbol replace <name> --path <file> --body <code>` | Replace symbol body | | `symbol insert-after <name> --path <file> --content <code>` | Insert after symbol | | `symbol insert-before <name> --path <file> --content <code>` | Insert before symbol | | `symbol rename <name> <new> --path <file>` | Rename symbol | ### Memory Commands | Command | Description | |---------|-------------| | `memory list` | List all memories | | `memory read <name>` | Read memory content | | `memory write <name> --content <text>` | Create/update memory | | `memory edit <name> --content <text>` | Edit memory | | `memory delete <name>` | Delete memory | ### File Commands | Command | Description | |---------|-------------| | `file list [--path dir] [--recursive]` | List directory | | `file find <pattern>` | Find files by glob pattern | | `file search <pattern> [--path dir]` | Search for regex pattern | ### Extended Commands | Command | Description | |---------|-------------| | `cmd run <command> [--cwd dir] [--timeout N]` | Execute shell command | | `cmd script <path> [--args "..."]` | Execute script file | | `config read <path> [--format json\|yaml]` | Read config file | | `config update <path> <key> <value>` | Update config value | ### Workflow Commands | Command | Description | |---------|-------------| | `workflow onboarding` | Run project onboarding | | `workflow check` | Check onboarding status | | `workflow tools [--scope all]` | List available tools | ## Workflow Examples ### Phase 1: Exploration ```bash python -m tools symbol overview src/main.py # Understand file structure python -m tools symbol find MyClass --depth 1 # Explore class members python -m tools symbol find MyClass/method --body # Get implementation details ``` ### Phase 2: Analysis ```bash python -m tools symbol refs MyClass/method # Impact analysis python -m tools memory list # Check project knowledge python -m tools memory read architecture # Retrieve context ``` ### Phase 3: Modification ```bash python -m tools symbol find target --body # Verify target python -m tools symbol replace target --path f --body "..." # Edit python -m tools symbol rename old new --path f # Refactor ``` ## Error Handling All CLI output is JSON: ```json // Success {"result": <data>} // Error {"error": {"code": "ERROR_CODE", "message": "description"}} ``` | Error Code | Recovery | |------------|----------| | `INVALID_ARGS` | Check `--help` | | `TOOL_NOT_FOUND` | Use `workflow tools` | | `INIT_FAILED` | Check serena-agent installation | | `RUNTIME_ERROR` | Check error message | ## Anti-Patterns | Prohibited | Correct | |------------|---------| | Read entire file to find function | `symbol find func --body` | | Grep for function calls | `symbol refs func` | | Manual search-replace rename | `symbol rename old new --path f` | | Skip impact analysis | `symbol refs` before editing |
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.