Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ubuntu-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ jobs:
-r requirements/requirements-playwright.in \
".[help,playwright]"

- name: Configure Git for tests
run: |
git config --global user.name "Test User"
git config --global user.email "test@example.com"
git config --global init.defaultBranch main

- name: Run tests
run: |
pytest
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/windows-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ jobs:
pip install uv
uv pip install --system pytest pytest-asyncio pytest-mock -r requirements/requirements.in -r requirements/requirements-help.in -r requirements/requirements-playwright.in '.[help,playwright]'

- name: Configure Git for tests
run: |
git config --global user.name "Test User"
git config --global user.email "test@example.com"
git config --global init.defaultBranch main

- name: Run tests
run: |
pytest
Expand Down
2 changes: 1 addition & 1 deletion cecli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from packaging import version

__version__ = "0.98.2.dev"
__version__ = "0.99.0.dev"
safe_version = __version__

try:
Expand Down
13 changes: 13 additions & 0 deletions cecli/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,19 @@ def get_parser(default_config_files, git_root):
help="Specify TUI Mode configuration as a JSON string",
default=None,
)
#########
group = parser.add_argument_group("Workspace Settings")
group.add_argument(
"--workspaces",
type=str,
help="JSON/YAML configuration for workspace initialization",
)
group.add_argument(
"--workspace-name",
type=str,
help="Specify the workspace name to activate",
)

#########
group = parser.add_argument_group("Agent Settings")
group.add_argument(
Expand Down
4 changes: 3 additions & 1 deletion cecli/coders/agent_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1037,7 +1037,9 @@ def _generate_tool_context(self, repetitive_tools):
)
self.model_kwargs["frequency_penalty"] = min(0, max(freq_penalty - 0.15, 0))

self.model_kwargs["temperature"] = max(0, min(self.model_kwargs["temperature"], 1))
self.model_kwargs["temperature"] = max(
0, min(nested.getter(self.model_kwargs, "temperature", 1), 1)
)
# One twentieth of the time, just straight reset the randomness
if random.random() < 0.05:
self.model_kwargs = {}
Expand Down
23 changes: 20 additions & 3 deletions cecli/coders/base_coder.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
)
from cecli.repo import ANY_GIT_ERROR, GitRepo
from cecli.repomap import RepoMap
from cecli.report import update_error_prefix
from cecli.run_cmd import run_cmd
from cecli.sessions import SessionManager
from cecli.tools.utils.output import print_tool_response
Expand Down Expand Up @@ -531,6 +532,11 @@ def __init__(
has_map_prompt = nested.getter(self, "gpt_prompts.repo_content_prefix")

if use_repo_map and self.repo and has_map_prompt:
repo_root = (
self.repo.workspace_path
if (self.repo and getattr(self.repo, "workspace_path", None))
else self.root
)
self.repo_map = RepoMap(
map_tokens,
self.map_cache_dir,
Expand All @@ -542,7 +548,7 @@ def __init__(
map_mul_no_files=map_mul_no_files,
refresh=map_refresh,
max_code_line_length=map_max_line_length,
repo_root=self.root,
repo_root=repo_root,
use_memory_cache=repomap_in_memory,
use_enhanced_map=getattr(self.args, "use_enhanced_map", False),
)
Expand Down Expand Up @@ -1508,6 +1514,9 @@ async def output_task(self, preproc):
except (SwitchCoderSignal, SystemExit):
raise
except Exception as e:
traceback_str = traceback.format_exc()
update_error_prefix(traceback_str)

if self.verbose or self.args.debug:
print(e)

Expand Down Expand Up @@ -3651,13 +3660,14 @@ def get_all_relative_files(self):
# Continue to get tracked files normally

if self.repo:
if not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file():
if hasattr(self.repo, "workspace_path") and self.repo.workspace_path:
files = self.repo.get_workspace_files()
elif not self.repo.cecli_ignore_file or not self.repo.cecli_ignore_file.is_file():
files = self.repo.get_tracked_files()
else:
files = self.repo.get_non_ignored_files_from_root()
else:
files = self.get_inchat_relative_files()

# This is quite slow in large repos
# files = [fname for fname in files if self.is_file_safe(fname)]

Expand Down Expand Up @@ -3919,6 +3929,13 @@ async def auto_commit(self, edited, context=None):
if not self.repo or not self.auto_commits or self.dry_run:
return

# Workspace-aware commit logic
if hasattr(self.args, "workspace") and self.args.workspace:
# We are in a workspace context.
# The GitRepo instance (self.repo) should already be pointing to the correct worktree/repo
# within the workspace because of the os.chdir in main.py and detection in repo.py.
pass

if not context:
context = self.get_context_from_history(
ConversationService.get_manager(self).get_messages_dict(MessageTag.CUR)
Expand Down
3 changes: 3 additions & 0 deletions cecli/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
from .voice import VoiceCommand
from .weak_model import WeakModelCommand
from .web import WebCommand
from .workspace import WorkspaceCommand

# Register commands
CommandRegistry.register(AddCommand)
Expand Down Expand Up @@ -143,6 +144,7 @@
CommandRegistry.register(VoiceCommand)
CommandRegistry.register(WeakModelCommand)
CommandRegistry.register(WebCommand)
CommandRegistry.register(WorkspaceCommand)


__all__ = [
Expand Down Expand Up @@ -220,4 +222,5 @@
"VoiceCommand",
"WeakModelCommand",
"WebCommand",
"WorkspaceCommand",
]
72 changes: 72 additions & 0 deletions cecli/commands/workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import subprocess

from cecli.commands.utils.base_command import BaseCommand


class WorkspaceCommand(BaseCommand):
NORM_NAME = "workspace"
DESCRIPTION = "Print information about the current workspace"

@classmethod
async def execute(cls, io, coder, args, **kwargs):
"""Execute the workspace command."""
if not coder or not coder.repo:
io.tool_output("No repository or workspace active.")
return

workspace_path = getattr(coder.repo, "workspace_path", None)
if not workspace_path:
io.tool_output("Not currently working within a cecli workspace.")
return

import json

metadata_path = workspace_path / ".cecli-workspace.json"
config = {}
if metadata_path.exists():
try:
with open(metadata_path, "r") as f:
config = json.load(f)
except Exception:
pass

ws_name = config.get("name", workspace_path.name)
is_active = config.get("active", False)

io.print(f"Current Workspace: {ws_name}{' (Active)' if is_active else ''}")
io.print(f"Root Directory: {workspace_path}")
io.print("-" * 40)
io.print("Projects:")

projects = config.get("projects", [])
for proj in projects:
proj_name = proj.get("name")
if not proj_name:
continue

proj_root = workspace_path / proj_name / "main"
branch_info = "Unknown"
if proj_root.exists():
try:
branch_info = subprocess.check_output(
["git", "-C", str(proj_root), "rev-parse", "--abbrev-ref", "HEAD"],
stderr=subprocess.DEVNULL,
encoding="utf-8",
).strip()
except Exception:
branch_info = "Error retrieving branch"

repo_url = proj.get("repo", "N/A")
io.print(f" - {proj_name}:")
io.print(f" Branch: {branch_info}")
io.print(f" Remote: {repo_url}")
io.print(f" Path: {proj_root}")
io.print("")

@classmethod
def get_help(cls) -> str:
"""Get help text for the workspace command."""
help_text = super().get_help()
help_text += "\nUsage:\n"
help_text += " /workspace # Show details of the active monorepo workspace\n"
return help_text
Empty file.
149 changes: 149 additions & 0 deletions cecli/helpers/monorepo/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import json
from pathlib import Path
from typing import Any, Dict, Optional

import yaml


def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]:
"""
Common logic to resolve workspace configuration from hierarchy:
1. config_arg (JSON string)
2. Local .cecli.workspaces.yml/yaml
3. Global ~/.cecli/workspaces.yml/yaml
4. Fallback to .cecli.conf.yml
"""
workspace_conf = None

# 1. Try config_arg (JSON string from main.py)
if config_arg:
try:
loaded = json.loads(config_arg)
if isinstance(loaded, dict):
workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded
elif isinstance(loaded, list):
workspace_conf = loaded
except json.JSONDecodeError:
try:
loaded = yaml.safe_load(config_arg)
if isinstance(loaded, dict):
workspace_conf = loaded.get("workspaces") or loaded.get("workspace") or loaded
elif isinstance(loaded, list):
workspace_conf = loaded
except yaml.YAMLError:
pass

# 2. Look for local .cecli.workspaces.yml/yaml
if not workspace_conf:
for local_name in [".cecli.workspaces.yml", ".cecli.workspaces.yaml"]:
local_path = Path(local_name)
if local_path.exists():
try:
with open(local_path, "r") as f:
loaded = yaml.safe_load(f)
if loaded:
workspace_conf = (
loaded.get("workspaces") or loaded.get("workspace") or loaded
)
if workspace_conf:
break
except Exception:
pass

# 3. Look for global ~/.cecli/workspaces.yml/yaml
if not workspace_conf:
for global_name in ["workspaces.yml", "workspaces.yaml"]:
global_path = Path.home() / ".cecli" / global_name
if global_path.exists():
try:
with open(global_path, "r") as f:
loaded = yaml.safe_load(f)
if loaded:
workspace_conf = (
loaded.get("workspaces") or loaded.get("workspace") or loaded
)
if workspace_conf:
break
except Exception:
pass

return workspace_conf


def load_workspace_config(
config_arg: Optional[str] = None, name: Optional[str] = None
) -> Dict[str, Any]:
"""
Load workspace configuration from hierarchy.
If name is provided, select that specific workspace from a list.
"""
workspace_conf = resolve_workspace_config(config_arg)

config = {}
# Handle list of workspaces or single dict
if isinstance(workspace_conf, list):
if name:
selected_ws = next((ws for ws in workspace_conf if ws.get("name") == name), None)
if not selected_ws:
raise ValueError(f"Workspace '{name}' not found in configuration")
config = selected_ws
else:
active_workspaces = [ws for ws in workspace_conf if ws.get("active")]
if len(active_workspaces) > 1:
active_names = [ws.get("name", "unknown") for ws in active_workspaces]
raise ValueError(f"Multiple workspaces marked as active: {', '.join(active_names)}")

active_ws = active_workspaces[0] if active_workspaces else None

# If no workspace is explicitly marked active, but there is only one, use it
if not active_ws and len(workspace_conf) == 1:
active_ws = workspace_conf[0]
config = active_ws if active_ws else {}
elif isinstance(workspace_conf, dict):
config = workspace_conf

validate_config(config)
return config


def validate_config(config: Dict[str, Any]) -> None:
"""
Minimal validation of required fields.
"""
if not config:
return

if "name" not in config:
raise ValueError("Workspace configuration must include a 'name'")

if "projects" not in config:
config["projects"] = []

project_names = set()
for project in config["projects"]:
if "name" not in project or "repo" not in project:
raise ValueError("Each project must have a 'name' and 'repo' URL")
if project["name"] in project_names:
raise ValueError(f"Duplicate project name: {project['name']}")
project_names.add(project["name"])


def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]:
"""
Find the name of the active workspace from the config without resolving it fully.
Used in main.py to automatically activate a workspace.
"""
workspace_conf = resolve_workspace_config(config_arg)

if isinstance(workspace_conf, list):
active_ws = next((ws for ws in workspace_conf if ws.get("active")), None)
if active_ws:
return active_ws.get("name")
# If there's only one workspace, it's considered active
if len(workspace_conf) == 1:
return workspace_conf[0].get("name")
elif isinstance(workspace_conf, dict):
# If it's a single dict, it's considered active by default
return workspace_conf.get("name")

return None
Loading
Loading