diff --git a/cecli/__init__.py b/cecli/__init__.py index 19645dbc949..6fe485a8330 100644 --- a/cecli/__init__.py +++ b/cecli/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "1.3.1.dev" +__version__ = "1.3.2.dev" safe_version = __version__ try: diff --git a/cecli/args.py b/cecli/args.py index 1dcebfb2584..43d7e8b1e92 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -331,6 +331,12 @@ def get_parser(default_config_files, git_root): default=3, help="Maximum number of retries a model gets on malformed outputs (default: 3)", ) + group.add_argument( + "--max-tool-calls", + type=int, + default=25, + help="Maximum number of tool calls allowed per message (default: 25)", + ) group.add_argument( "--cost-limit", type=float, diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 08370f38e46..a3cf00ccb1d 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -105,8 +105,27 @@ def __init__(self, *args, **kwargs): self.sub_agent_paths = self.agent_config.get("subagent_paths", []) self._setup_agent() - AgentService.build_registry(self.sub_agent_paths) - ToolRegistry.build_registry(agent_config=self.agent_config) + # primary_root is needed early so the skills and tool registries can + # resolve relative paths against the primary workspace root rather than + # this coder's (possibly overridden) local root. base_coder re-affirms + # and keeps this value in super().__init__(). + self.primary_root = kwargs.get("primary_root") or getattr(self, "primary_root", None) + + # Allow a sub-agent to target a different working root (multi-project workspace). + if not kwargs.get("root"): + configured_root = self.agent_config.get("root") + if configured_root: + kwargs["root"] = configured_root + + self._initialize_skills_manager( + self.agent_config, root=kwargs.get("root", self.primary_root) + ) + AgentService.build_registry( + self.sub_agent_paths, root=kwargs.get("root", self.primary_root) + ) + ToolRegistry.build_registry( + agent_config=self.agent_config, root=kwargs.get("root", self.primary_root) + ) self.loaded_custom_tools = ToolRegistry.loaded_custom_tools super().__init__(*args, **kwargs) @@ -133,6 +152,8 @@ def post_init(self): self.io.tool_warning(err) self.start_up_errors = [] + # Preserve the original 10000 setting now that max tool calls is configurable + self.max_tool_calls = self.max_tool_calls * 400 def _setup_agent(self): os.makedirs(".cecli/temp", exist_ok=True) @@ -290,21 +311,19 @@ def _get_agent_config(self, config=None): config["tools_excludelist"].append("loadskill") config["tools_excludelist"].append("removeskill") - self._initialize_skills_manager(config) return config - def _initialize_skills_manager(self, config): + def _initialize_skills_manager(self, config, root=None): """ Initialize the skills manager with the configured directory paths and filters. """ try: - git_root = str(self.repo.root) if self.repo else None self.skills_manager = SkillsManager( directory_paths=config.get("skills_paths", []), include_list=config.get("skills_includelist", []), exclude_list=config.get("skills_excludelist", []), initialize_list=config.get("skills_init", []), - git_root=git_root, + root=root or self.root or self.primary_root, coder=self, ) except Exception as e: @@ -731,7 +750,8 @@ def get_context_summary(self): def get_environment_info(self): """ Generate an environment information context block with key system details. - Returns formatted string with working directory, platform, date, and other relevant environment details. + Returns formatted string with working directory, platform, date, and other relevant environment details, + including the agent's own identity and the primary agent's UUID. """ if not self.use_enhanced_context: return None @@ -745,6 +765,17 @@ def get_environment_info(self): result += f"- Current date: {current_date}\n" result += f"- Platform: {platform_info}\n" result += f"- Language preference: {language}\n" + + # Agent identity so the model can recognise itself and the primary + # agent. Kept here (a static context block) rather than in the + # sub-agent states block to avoid re-injecting an agent's own + # identity whenever sub-agent state changes mid-conversation. + service = AgentService.get_instance(self) + self_uuid = str(self.uuid) + primary_uuid = str(service.coder.uuid) + self_name = service.get_agent_name(self) or "primary" + result += f"- Agent: {self_name} ({self_uuid}) [self]\n" + result += f"- Primary agent: primary ({primary_uuid})\n" if self.repo: try: rel_repo_dir = self.repo.get_rel_repo_dir() @@ -1221,15 +1252,6 @@ def _generate_tool_context(self, repetitive_tools): for i, tool in enumerate(recent_history, 1): context_parts.append(f"{i}. {tool}") - if not self.edit_allowed: - context_parts.append("\n\n") - context_parts.append("## File Editing Tools Disabled") - context_parts.append( - "File editing tools are currently disabled. Use `ReadFile` to determine the" - " current content ID prefixes needed to perform an edit and activate them when" - " you are ready to edit a file." - ) - context_parts.append("\n\n") repetition_warning = None @@ -1706,7 +1728,7 @@ def get_sub_agents_context(self): result = '\n' result += "## Available Sub-Agents\n\n" - result += f"Found {len(registry)} registered sub-agent(s):\n\n" + result += f"Found {len(registry)} registered sub-agent(s) types:\n\n" for name, config in sorted(registry.items()): result += f"**{name}**:\n" @@ -1715,57 +1737,107 @@ def get_sub_agents_context(self): result += f"{desc}\n" result += "\n" - result += "Use the `Delegate` tool with the sub-agent name to delegate tasks.\n" - result += "Use the `Yield` tool to wait for responses for all active sub agents.\n" + result += "Use the `Delegate` tool with the sub-agent type to delegate tasks.\n" + result += "Use the `Yield` tool to wait for responses for all child sub agents.\n" result += "" return result except Exception as e: self.io.tool_error(f"Error generating sub-agents context: {str(e)}") return None - def get_child_agent_states(self): - """Get the state of all active child sub-agents. + def get_sub_agent_states(self): + """Get the state of all active sub-agents. + + Returns a formatted context block listing each active sub-agent as + ``{name} ({uuid}, {status})`` bullets, for both dependent children and + independent sub-agents. The independent ones are those reachable via + the ``Broadcast`` tool. + + Finished/errored sub-agents are excluded. Returns None if there are no + active sub-agents, if enhanced context is disabled, or if the caller is + a sub-agent without nested delegation enabled. + + The primary agent and calling agent's own identity are deliberately not + included here — they live in ``get_environment_info()`` (a static block) + so they don't re-inject and churn the conversation when sub-agent state + changes. - Returns a formatted context block with each child sub-agent's name, - UUID, and current status, or None if no children exist. This is used by ConversationChunks.add_sub_agent_states() to provide the model with visibility into active sub-agent states. """ if not self.use_enhanced_context: return None - # Sub-agents should only see child states when nested delegation is enabled + # Sub-agents should only see sub-agent states when nested delegation is enabled if hasattr(self, "parent_uuid") and self.parent_uuid: if not self.agent_config.get("allow_nested_delegation", False): return None try: service = AgentService.get_instance(self) + + from cecli.helpers.agents.service import SubAgentStatus + + originator_uuid = str(self.uuid) + + # Dependent children are the coder's direct children. children = service.get_children(self) - if not children: - return None + dependent_children = [ + info + for info in children + if not info.independent + and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR) + ] - # Filter to non-independent children only - dependent_children = [info for info in children if not info.independent] + # Independent agents aren't all direct children, so iterate over + # every active sub-agent reachable via the Broadcast tool and dedupe + # against the dependent children already captured above. + dependent_uuids = {info.coder.uuid for info in dependent_children} + independent_agents = [] + seen = set() + for info in service.sub_agents.values(): + if ( + info.independent + and str(info.coder.uuid) != originator_uuid + and info.status not in (SubAgentStatus.ERROR,) + and info.coder.uuid not in dependent_uuids + and info.coder.uuid not in seen + ): + seen.add(info.coder.uuid) + independent_agents.append(info) - if not dependent_children: + if not dependent_children and not independent_agents: return None result = '\n' - result += "## Active Sub-Agent States\n\n" - result += f"Found {len(dependent_children)} active child sub-agent(s):\n\n" - - for info in dependent_children: - result += f"**{info.name}**:\n" - result += f" - UUID: `{info.coder.uuid}`\n" - result += f" - Status: {info.status.value}\n" - if info.error: - result += f" - Error: {info.error}\n" - result += "\n" + result += "## Active Sub-Agent Instances\n\n" + result += f"Found {len(dependent_children) + len(independent_agents)} active sub-agent(s):\n\n" + + if dependent_children: + for info in dependent_children: + line = f"- {info.name} ({info.coder.uuid}, {info.status.value})" + if info.error: + line += f" - Error: {info.error}" + result += line + "\n" + + if independent_agents: + result += "## Independent Sub-Agent Instances\n" + result += ( + "Communicate bi-directionally with these sub-agents using the " + "`Broadcast` tool:\n\n" + ) + + for info in independent_agents: + line = f"- {info.name} ({info.coder.uuid}, {info.status.value})" + if info.error: + line += f" - Error: {info.error}" + result += line + "\n" + result += "" return result + except Exception as e: - self.io.tool_error(f"Error generating child agent states: {str(e)}") + self.io.tool_error(f"Error generating sub-agent states: {str(e)}") return None def get_background_command_output(self): diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 052886e1f47..0e196b76ec0 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -60,7 +60,7 @@ remove_reasoning_content, replace_reasoning_tags, ) -from cecli.repo import ANY_GIT_ERROR, GitRepo, GitRepoProxy +from cecli.repo import ANY_GIT_ERROR, GitRepoProxy from cecli.repomap import RepoMap from cecli.report import update_error_prefix from cecli.run_cmd import run_cmd_async @@ -193,6 +193,8 @@ def total_cached_tokens(self, value): abs_read_only_stubs_fnames = None abs_rules_fnames = None repo = None + root = "." + primary_root = None last_coder_commit_hash = None coder_edited_files = None last_asked_for_commit_time = 0 @@ -327,6 +329,7 @@ async def create( uuid=from_coder.uuid, parent_uuid=from_coder.parent_uuid, repo=from_coder.repo, + primary_root=from_coder.primary_root, summarizer=from_coder.summarizer, ) use_kwargs.update(update) # override to complete the switch @@ -451,6 +454,8 @@ def __init__( registered_servers=None, uuid: str = "", parent_uuid: str = "", + root=None, + primary_root=None, init_metadata={}, ): from cecli.helpers.agents.service import AgentService @@ -520,6 +525,7 @@ def __init__( self.max_compaction_retries = max_compaction_retries self.max_reflections = nested.getter(self.args, "max_reflections", 3) + self.max_tool_calls = nested.getter(self.args, "max_tool_calls", 25) if not fnames: fnames = [] @@ -621,13 +627,12 @@ def __init__( self.repo = repo if use_git and self.repo is None: try: - self.repo = GitRepoProxy( - GitRepo( - self.io, - fnames, - None, - models=main_model.commit_message_models(), - ) + self.repo = GitRepoProxy.for_root( + None, + self.io, + fnames=fnames, + git_dname=None, + models=main_model.commit_message_models(), ) except FileNotFoundError: pass @@ -664,12 +669,47 @@ def __init__( if not self.repo: self.root = utils.find_common_root(self.abs_fnames) - # Initialize the FileSystemService singleton for all agents - FileSystemService.get_instance( + # Allow sub-agent classes to override the working root (multi-project workspaces). + if root is not None: + self.root = os.path.normpath(os.path.abspath(root)) + + # A sub-agent may operate on a different base path than its parent. In + # that case its repo must be scoped to *this* root (per-base-path), + # rather than inheriting the parent's repo, so the coder's repo/fs match + # its own root. + if use_git and self.repo is not None and os.path.normpath(self.repo.root) != self.root: + try: + self.repo = GitRepoProxy.for_root( + self.root, + self.io, + fnames=[self.root], + git_dname=None, + models=main_model.commit_message_models(), + ) + except FileNotFoundError: + self.repo = None + + # Store the root of the primary coder so skills files, custom tools, and + # sub-agent paths can be resolved relative to the primary workspace even + # when this coder operates on a different base path. + self.primary_root = ( + primary_root + if primary_root is not None + else getattr(self, "primary_root", None) or self.root + ) + + # Initialize the per-base-path FileSystemService for this coder + self.fs = FileSystemService.for_root( root=self.root if hasattr(self, "root") else ".", repo=self.repo if hasattr(self, "repo") else None, ) + # Auto-return the per-root service (and its git repo) when the last + # coder sharing this base path is destroyed. + _fs_key = FileSystemService._normalize_root(self.root if hasattr(self, "root") else ".") + FileSystemService._inc_ref(_fs_key) + weakref.finalize(self, FileSystemService._release, _fs_key) + if read_only_fnames: self.abs_read_only_fnames = set() for fname in read_only_fnames: @@ -710,11 +750,7 @@ 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 - ) + repo_root = self.root self.repo_map = RepoMap( map_tokens, self.map_cache_dir, @@ -1048,6 +1084,25 @@ def abs_root_path(self, path): fences = all_fences fence = fences[0] + def resolve_relative_to_primary_root(self, path: str) -> str: + """Resolve a path relative to the primary coder's root (falling back to self.root). + + Used for skills files, custom tools, and sub-agent paths that are defined + relative to the primary workspace even when this coder operates on a + different base path. + """ + if not path: + return path + if path.startswith("/"): + # POSIX-style absolute path (e.g. "/tmp/foo"). os.path.isabs() + # returns False for "/"-rooted paths on Windows, so guard here + # to avoid re-anchoring them onto the primary root. + return path + if os.path.isabs(path): + return os.path.normpath(path) + base = self.primary_root or self.root + return os.path.normpath(os.path.join(base, path)) + def show_pretty(self): if not self.pretty: return False @@ -4567,8 +4622,8 @@ def is_file_safe(self, fname): return def get_all_relative_files(self): - """Get all files known to the file service singleton.""" - fs = FileSystemService.get_instance() + """Get all files known to the file service for this coder's base path.""" + fs = getattr(self, "fs", None) or FileSystemService.get_instance() if fs.trie: # Auto-rebuild if the repository state has changed # (e.g., new commits, staged files, or HEAD change) diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 0905610dc01..9f240a549ba 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -55,6 +55,7 @@ from .model import ModelCommand from .models import ModelsCommand from .multiline_mode import MultilineModeCommand +from .open import OpenCommand from .paste import PasteCommand from .queue import QueueCommand from .quit import QuitCommand @@ -151,6 +152,7 @@ CommandRegistry.register(ModelCommand) CommandRegistry.register(ModelsCommand) CommandRegistry.register(MultilineModeCommand) +CommandRegistry.register(OpenCommand) CommandRegistry.register(PasteCommand) CommandRegistry.register(QueueCommand) CommandRegistry.register(QuitCommand) @@ -240,6 +242,7 @@ "ModelCommand", "ModelsCommand", "MultilineModeCommand", + "OpenCommand", "parse_quoted_filenames", "PasteCommand", "quote_filename", diff --git a/cecli/commands/open.py b/cecli/commands/open.py new file mode 100644 index 00000000000..ee0db6c2523 --- /dev/null +++ b/cecli/commands/open.py @@ -0,0 +1,76 @@ +"""Open command - register and open a workspace sub-agent rooted at a path.""" + +from pathlib import Path + +from cecli.helpers.agents.service import AgentService +from cecli.helpers.workspaces.subagents import register_workspace_subagents + +from .utils.base_command import BaseCommand + + +class OpenCommand(BaseCommand): + NORM_NAME = "open" + DESCRIPTION = "Open a workspace sub-agent rooted at a given path" + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Open a workspace sub-agent rooted at the given path. + + Syntax: + /open — register and open a ``ws:{name}`` sub-agent rooted at ```` + """ + parts = args.strip().split(maxsplit=1) + if len(parts) < 2: + io.tool_error("Usage: /open ") + return + + name = parts[0] + path_arg = parts[1].strip() + + project_name = name[3:] if name.startswith("ws:") else name + agent_name = f"ws:{project_name}" + path = Path(path_arg).expanduser() + + config = { + "name": project_name, + "projects": [{"name": project_name, "path": str(path)}], + } + registered = register_workspace_subagents(config) + if agent_name not in registered: + io.tool_error(f"Error: '{path}' is not a valid git repository or does not exist.") + return + + root = AgentService.get_registry()[agent_name].metadata.get("root") + + try: + agent_service = AgentService.get_instance(coder) + new_coder, info = await agent_service.spawn( + agent_name, prompt=None, parent=coder, auto_reap=False, independent=True + ) + + agent_service.foreground_uuid = info.coder.uuid + + if coder.tui and coder.tui(): + tui = coder.tui() + switch_key = tui.get_keys_for("next_agent") + io.tool_output( + f"Opened workspace sub-agent '{agent_name}' rooted at {root}. Switch with {switch_key}" + ) + + try: + tui.call_from_thread(tui._switch_to_container, info.coder.uuid) + except Exception: + pass + else: + io.tool_output(f"Opened workspace sub-agent '{agent_name}' rooted at {root}.") + except Exception as e: + io.tool_error(f"Error opening workspace sub-agent '{agent_name}': {e}") + + @classmethod + def get_help(cls) -> str: + return "Open a workspace sub-agent rooted at a path (/open )" + + @classmethod + def get_completions(cls, io, coder, args) -> list[str]: + """Return registered workspace sub-agent names for tab-completion.""" + return [name for name in AgentService.get_registry().keys() if name.startswith("ws:")] diff --git a/cecli/commands/utils/helpers.py b/cecli/commands/utils/helpers.py index b70fa0c40c1..2b4670c0ba6 100644 --- a/cecli/commands/utils/helpers.py +++ b/cecli/commands/utils/helpers.py @@ -159,7 +159,7 @@ def get_file_completions( root = Path(coder.root) if hasattr(coder, "root") else Path.cwd() # Try using FileSystemService for efficient lookups - fs = FileSystemService.get_instance() + fs = getattr(coder, "fs", None) or FileSystemService.get_instance() fs_available = fs.trie is not None or fs.ngram is not None if completion_type == "glob": diff --git a/cecli/commands/workspace.py b/cecli/commands/workspace.py index ba53f260fe5..4fe1aa4fb22 100644 --- a/cecli/commands/workspace.py +++ b/cecli/commands/workspace.py @@ -1,68 +1,29 @@ -import subprocess - from cecli.commands.utils.base_command import BaseCommand -from cecli.decoding import safe_open class WorkspaceCommand(BaseCommand): NORM_NAME = "workspace" - DESCRIPTION = "Print information about the current workspace" + DESCRIPTION = "Print information about the active workspace sub-agents" show_completion_notification = True @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 + """Show the registered ws:{name} workspace sub-agents and their roots.""" + from cecli.helpers.agents.service import AgentService - metadata_path = workspace_path / ".cecli-workspace.json" - config = {} - if metadata_path.exists(): - try: - with safe_open(metadata_path, "r") as f: - config = json.load(f) - except Exception: - pass + registry = AgentService.get_registry() + ws_agents = sorted((name, cfg) for name, cfg in registry.items() if name.startswith("ws:")) - 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" + if not ws_agents: + io.tool_output("No workspace sub-agents are active.") + return - 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("Workspace Sub-Agents:") + for name, cfg in ws_agents: + metadata = getattr(cfg, "metadata", {}) or {} + io.print(f" - {name}") + io.print(f" Root: {metadata.get('root')}") + io.print(f" Layout: {metadata.get('layout')}") io.print("") @classmethod @@ -70,5 +31,5 @@ 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" + help_text += " /workspace # List active workspace sub-agents\n" return help_text diff --git a/cecli/helpers/agents/defaults/memorizer.md b/cecli/helpers/agents/defaults/memorizer.md index 9c9618c5ee9..3b454bf7d30 100644 --- a/cecli/helpers/agents/defaults/memorizer.md +++ b/cecli/helpers/agents/defaults/memorizer.md @@ -16,6 +16,8 @@ agent-config: - gitremote - gitshow - gitstatus + servers_includelist: + - local exclude_context_blocks: - context_summary - directory_structure diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index 2f39d013e8e..b0c9115260b 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -14,6 +14,7 @@ from uuid import uuid4 import cecli.models as models +from cecli.helpers import nested from cecli.helpers.coroutines import fire_and_forget logger = logging.getLogger(__name__) @@ -186,7 +187,7 @@ def mark_sub_agent_finished( return @classmethod - def build_registry(cls, paths: List[str]) -> None: + def build_registry(cls, paths: List[str], root: Optional[str] = None) -> None: """Scan directories for .md sub-agent definition files and load them. Each .md file should contain YAML front matter with: @@ -204,16 +205,45 @@ def build_registry(cls, paths: List[str]) -> None: ```` - The parent coder's agent model ```` - The parent coder's main model ```` - The currently active (foreground) coder's main model + + Args: + paths: Directories to scan for sub-agent ``.md`` definition files. + root: Optional base directory used to resolve relative ``paths`` + (e.g. the workspace or project root in multi-project workspaces). """ from pathlib import Path from .config import parse_subagent_file + # Resolve relative sub-agent directories against ``root`` so + # multi-project workspaces can reference local sub-agent definitions + # relative to their project/workspace root. Absolute and ``~`` paths + # are left untouched. + base = Path(root).expanduser().resolve() if root else None + + def _resolve(directory: str) -> str: + raw = Path(directory).expanduser() + if raw.is_absolute(): + return str(raw) + if base is not None: + return str((base / raw).resolve()) + return str(raw) + + paths = [_resolve(directory) for directory in paths] + # Always check the default sub-agents directory in the user's home default_dir = str(Path.home() / ".cecli" / "subagents") if default_dir not in paths: paths = [default_dir] + list(paths) + # Also check the local default sub-agents directory under the given root + # (e.g. {root}/.cecli/subagents) before the user-configured paths so + # project-scoped sub-agents take precedence over the global default. + if base is not None: + local_default_dir = str(base / ".cecli" / "subagents") + if local_default_dir not in paths: + paths.insert(1, local_default_dir) + # Also scan the built-in defaults directory for .yml definitions import cecli.helpers.agents.defaults as _defaults_pkg @@ -553,6 +583,22 @@ async def _create_sub_agent_coder( metadata = getattr(config, "metadata", {}).copy() agent_config = metadata.get("agent-config", {}) + # Optional per-sub-agent root override for multi-project workspaces. + configured_root = metadata.get("root") + if not configured_root and isinstance(agent_config, dict): + configured_root = agent_config.get("root") + + # Workspace sub-agents (ws:*) inherit the parent/primary coder's + # agent_config as their default and additionally enable nested + # delegation so they can themselves serve as delegation bases. The + # ws-agent's own metadata agent-config is merged on top. + if name.startswith("ws:"): + inherited = dict(nested.getter(parent_coder, "agent_config", {}) or {}) + incoming = dict(agent_config) + inherited.update(incoming) + inherited["allow_nested_delegation"] = True + agent_config = inherited + kwargs = dict( io=parent_coder.io, from_coder=parent_coder, @@ -563,6 +609,8 @@ async def _create_sub_agent_coder( map_tokens=0, init_metadata={"agent_config": agent_config}, ) + if configured_root: + kwargs["root"] = configured_root if agent_config: # Reset the per-instance tool/server filters so AgentCoder.post_init() @@ -759,6 +807,53 @@ def _raise_if_signal(exc): ) return task + def wake_primary(self, primary_coder: Any, message: str) -> str: + """Wake the primary coder so it processes *message*. + + Unlike sub-agents, the primary coder does not use the + ``start_generate_task`` architecture; when it is idle its input loop + blocks in ``wait_for_input()``. Preserving the behaviour of + ``on_input_area_submit()``, we wake it by pushing the message onto its + per-coder input queue. If the primary is already generating, the + message is queued into its conversation instead so the running + ``generate`` loop picks it up. + + Args: + primary_coder: The primary coder instance (``service.coder``). + message: The message text to deliver to the primary. + + Returns: + A short mode string describing delivery: ``queued`` when the + primary is actively generating (message queued into its + conversation), ``started`` when the primary was idle and woken via + its input queue. + """ + from cecli.helpers import queues + from cecli.helpers.conversation.service import ConversationService + from cecli.helpers.conversation.tags import MessageTag + from cecli.helpers.coroutines import is_active + + def _queue_to_conversation() -> None: + ConversationService.get_manager(primary_coder).queue_message( + message_dict={"role": "user", "content": message}, + tag=MessageTag.CUR, + hash_key=("broadcast", str(primary_coder.uuid), str(time.monotonic_ns())), + ) + + if is_active(getattr(primary_coder.io, "output_task", None)): + _queue_to_conversation() + return "queued" + + delivered = queues.push_coder_input( + str(primary_coder.uuid), + {"text": message, "coder_uuid": str(primary_coder.uuid)}, + ) + if not delivered: + _queue_to_conversation() + return "queued" + + return "started" + async def _inject_sub_agent_result(self, info: SubAgentInfo) -> None: """Inject the sub-agent's result (summary/error) into the parent's conversation. diff --git a/cecli/helpers/conversation/integration.py b/cecli/helpers/conversation/integration.py index 3ffdc22883e..6bb59fa5261 100644 --- a/cecli/helpers/conversation/integration.py +++ b/cecli/helpers/conversation/integration.py @@ -1,4 +1,5 @@ import json +import os import random import time import weakref @@ -501,6 +502,8 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: """ Get rules file messages for reference. These are always reloaded from disk and use the RULES tag. + If no explicit rules files are configured, fall back to AGENTS.md + and CLAUDE.md files found in the coder root directory. """ coder = self.get_coder() if not coder: @@ -514,10 +517,21 @@ def add_rules_messages(self) -> List[Dict[str, Any]]: return [] messages = [] - if not hasattr(coder, "abs_rules_fnames") or not coder.abs_rules_fnames: + rules_files = [] + if hasattr(coder, "abs_rules_fnames") and coder.abs_rules_fnames: + rules_files = sorted(coder.abs_rules_fnames) + else: + # No explicit rules configured; fall back to convention files + # (AGENTS.md / CLAUDE.md) found in the coder root directory. + for fallback_name in ("AGENTS.md", "CLAUDE.md"): + fallback_path = coder.abs_root_path(fallback_name) + if os.path.isfile(fallback_path): + rules_files.append(fallback_path) + + if not rules_files: return messages - for fname in sorted(coder.abs_rules_fnames): + for fname in rules_files: # Read file content directly from disk try: content = coder.io.read_text(fname) @@ -953,10 +967,10 @@ def add_sub_agent_states(self) -> None: if not hasattr(coder, "use_enhanced_context") or not coder.use_enhanced_context: return - if not hasattr(coder, "get_child_agent_states"): + if not hasattr(coder, "get_sub_agent_states"): return - block = coder.get_child_agent_states() + block = coder.get_sub_agent_states() if not block: return diff --git a/cecli/helpers/file_system/builders.py b/cecli/helpers/file_system/builders.py index 5389ea50ef8..6dbb7cdca67 100644 --- a/cecli/helpers/file_system/builders.py +++ b/cecli/helpers/file_system/builders.py @@ -8,6 +8,7 @@ import hashlib import os import subprocess +import time from pathlib import Path from .ignore import FileIgnoreFilter @@ -118,6 +119,8 @@ def collect( Walk filesystem collecting files relative to root using Breadth-First Search. Highly optimized: lazy iteration, single-pass evaluation, and raw string paths. """ + start_time = time.monotonic() + paths = [] # Only use pathlib for initial resolution, then stick to raw strings for speed @@ -132,6 +135,9 @@ def collect( path_count = 0 while dirs_to_scan: + if time.monotonic() - start_time > 120: + break + next_dirs = [] for current_path, rel_dir, depth in dirs_to_scan: @@ -159,7 +165,7 @@ def collect( # --- Handle Files --- elif entry.is_file(follow_symlinks=False): - # Short-circuit if we hit the 64 file cap (saves ignore_filter overhead) + # Short-circuit if we hit the 256 file cap (saves ignore_filter overhead) if not is_subfolder_of_user and file_count >= 256: continue @@ -177,7 +183,7 @@ def collect( # Global safety hard-stop if not is_subfolder_of_user and path_count >= max_files: return sorted(paths) - except PermissionError: + except (PermissionError, OSError, TimeoutError): continue # Skip folders we don't have read access to dirs_to_scan = next_dirs diff --git a/cecli/helpers/file_system/service.py b/cecli/helpers/file_system/service.py index 2965490859e..94edd58d23c 100644 --- a/cecli/helpers/file_system/service.py +++ b/cecli/helpers/file_system/service.py @@ -1,9 +1,15 @@ """ -FileSystemService: Global singleton for file path resolution and discovery. +FileSystemService: per-base-path singleton for file path resolution and discovery. -All agents (Coder, sub-agents, etc.) share one instance via get_instance(). -Initialized once on first call with root/repo params; subsequent calls -ignore them and return the existing singleton. +Each distinct base path owns exactly one instance, carrying its own repo, +marisa-trie, and trigram index. Use FileSystemService.for_root(root, repo) +to obtain the instance for a base path. Coders that share a base path share +the same instance; coders on different base paths get independent instances, +so multiple repositories can be served concurrently. + +get_instance() remains as a backward-compatible accessor that returns the +most-recently-active base path's instance when no root is given, and delegates +to for_root() when a root is provided. Uses marisa-trie for memory-efficient prefix/string matching and ngram for trigram-based fuzzy search. Supports construction from @@ -24,12 +30,15 @@ class FileSystemService: """ Provides file path resolution, prefix queries, and fuzzy search. - Singleton — all agents and sub-agents share one instance. - Use FileSystemService.get_instance() to obtain it. + Singleton per base path — all coders that share a base path use one + instance; coders on different base paths get independent instances. Use + FileSystemService.for_root() to obtain the instance for a base path. """ - # --- Singleton state --- - _instance = None + # --- Per-base-path singleton registry --- + _instances: dict[str, "FileSystemService"] = {} + _active_root: str | None = None + _refcounts: dict[str, int] = {} # --- Instance attributes --- root: str = "." @@ -44,7 +53,7 @@ def __init__( ): """ Initialize the service. Not intended for direct use — - call get_instance() instead. + call for_root() instead. """ self.root = os.path.normpath(root) self.repo = repo @@ -67,35 +76,104 @@ def ngram(self): # --------------------------------------------------------------- # Singleton access # --------------------------------------------------------------- + @staticmethod + def _normalize_root(root: str) -> str: + """Normalize a base path into a canonical registry key.""" + return os.path.normpath(os.path.abspath(root)) + @classmethod def reset_instance(cls) -> None: - """Reset the singleton — used primarily in test teardown.""" - cls._instance = None + """Reset all per-base-path singletons — used primarily in test teardown.""" + cls._instances.clear() + cls._refcounts.clear() + cls._active_root = None + + @classmethod + def for_root(cls, root: str | None = None, repo=None) -> "FileSystemService": + """ + Return the singleton for a base path. + + Each distinct base path owns exactly one instance carrying its own + repo and indices. The first call for a base path builds it; later + calls return the cached instance. If a different repo is supplied for + an already-cached base path, the instance's repo is re-pointed and the + index is invalidated so the next rebuild uses the new repo. + """ + root = root or "." + key = cls._normalize_root(root) + service = cls._instances.get(key) + + if service is None: + service = cls._create(root=root, repo=repo) + cls._instances[key] = service + elif repo is not None and service.repo is not repo: + service.repo = repo + service._build_hash = "" # Invalidate for lazy rebuild with new repo + cls._active_root = key + return service @classmethod def get_instance(cls, root: str | None = None, repo=None) -> "FileSystemService": """ - Return the global singleton. + Backward-compatible accessor (see the module docstring). - On first call, creates and builds the instance using root/repo. - Subsequent calls return the existing instance. If a non-None root - is explicitly provided and differs from the current root, the - singleton is rebuilt (e.g. when a new coder is created in a - different working directory). + With a root, delegates to for_root(). Without a root, returns the + most-recently-active base path's instance, building one for the + current directory if nothing is active yet. """ - if cls._instance is None: - cls._instance = cls._create(root=root or ".", repo=repo) - elif root is not None and root != cls._instance.root: - cls._instance = cls._create(root=root, repo=repo) - return cls._instance + if root is not None: + return cls.for_root(root=root, repo=repo) + if cls._active_root is None: + return cls.for_root(root=".", repo=repo) + return cls._instances[cls._active_root] + + @classmethod + def evict(cls, root: str | None = None) -> None: + """Drop the instance for a base path (e.g. on coder teardown).""" + if root is None: + return + key = cls._normalize_root(root) + cls._instances.pop(key, None) + cls._refcounts.pop(key, None) + if cls._active_root == key: + cls._active_root = None @classmethod def _create(cls, root: str, repo=None) -> "FileSystemService": - """Internal factory — builds and returns the singleton.""" + """Internal factory — builds and returns a per-base-path instance.""" service = cls(root=root, repo=repo) service.build() return service + @classmethod + def _inc_ref(cls, key: str) -> None: + """Bump the reference count for a base path held by a live coder.""" + cls._refcounts[key] = cls._refcounts.get(key, 0) + 1 + + @classmethod + def _release(cls, key: str) -> None: + """Drop one reference; evict the base-path service (and its git repo) + when the last coder using it is destroyed.""" + count = cls._refcounts.get(key, 0) + if count <= 1: + cls._evict_pair(key) + else: + cls._refcounts[key] = count - 1 + + @classmethod + def _evict_pair(cls, key: str) -> None: + """Evict the FileSystemService and the matching GitRepoProxy for a base path.""" + cls._instances.pop(key, None) + cls._refcounts.pop(key, None) + if cls._active_root == key: + cls._active_root = None + try: + from cecli.repo import GitRepoProxy + + GitRepoProxy.evict(key) + except Exception: + pass + def has_git(self) -> bool: """Check if a git repo is available at root.""" return self.repo is not None or (os.path.isdir(os.path.join(self.root, ".git"))) diff --git a/cecli/helpers/hashpos/hashpos.py b/cecli/helpers/hashpos/hashpos.py index bd5a150fa98..538fc483200 100644 --- a/cecli/helpers/hashpos/hashpos.py +++ b/cecli/helpers/hashpos/hashpos.py @@ -74,8 +74,8 @@ class HashPos: rf"^({UNIQUE_HASH_DELIMITER}|{HASH_DELIMITER}[{_B1024_REGEX_SET}]{{4}}{HASH_DELIMITER})$" ) - # Loose prefix for robust stripping: Matches a emdash-wrapped 4-char string containing non-ASCII - _LOOSE_PREFIX_RE = re.compile(rf"^[{HASH_DELIMITER}]?\S{{0,4}}{HASH_DELIMITER}") + # Loose prefix for robust stripping: Matches a emdash-wrapped 8-char string containing non-ASCII + _LOOSE_PREFIX_RE = re.compile(rf"^[{HASH_DELIMITER}]?\S{{0,8}}{HASH_DELIMITER}") def __init__(self, source_text: str = ""): self.lines = source_text.splitlines() diff --git a/cecli/helpers/hashpos/transformations.py b/cecli/helpers/hashpos/transformations.py index 6026f57cdaa..d481918cb90 100644 --- a/cecli/helpers/hashpos/transformations.py +++ b/cecli/helpers/hashpos/transformations.py @@ -10,13 +10,8 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING - -from cecli.helpers.hashpos.hashpos import UNIQUE_HASH_DELIMITER - -if TYPE_CHECKING: - from cecli.helpers.hashpos.hashpos import HashPos +from cecli.helpers.hashpos.hashpos import UNIQUE_HASH_DELIMITER, HashPos # ──────────────────────────────────────────────────────────── # Pattern Matching @@ -432,9 +427,24 @@ def reposition_indices( def strip_hashline_prefix(value: str) -> str: """Strip the virtual prefix from a ReadFile output line reference.""" - - if isinstance(value, str) and value.startswith(UNIQUE_HASH_DELIMITER): - return value[len(UNIQUE_HASH_DELIMITER) :].lstrip() + if not isinstance(value, str): + return value + + # Unique-line delimiter (——): not spatially resolvable, so strip it and match + # the remaining content as text. + if value.startswith(UNIQUE_HASH_DELIMITER): + return value[len(UNIQUE_HASH_DELIMITER) :] + + # Valid canonical duplicate content ID (—XXXX—): keep it intact so that + # resolve_content_to_hashline_ids() can target a specific occurrence. + if HashPos.HASH_PREFIX_RE.match(value): + return value + + # Malformed marker: a short string immediately followed by an em-dash (e.g. + # 'о‘星—'). This is not a valid content ID, so strip it. + stripped = HashPos._LOOSE_PREFIX_RE.sub("", value, count=1) + if stripped != value: + return stripped return value diff --git a/cecli/helpers/llms/domains/gemini.py b/cecli/helpers/llms/domains/gemini.py index b267ced2dcc..2e2aed1c07d 100644 --- a/cecli/helpers/llms/domains/gemini.py +++ b/cecli/helpers/llms/domains/gemini.py @@ -194,7 +194,7 @@ async def gemini_complete( url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:generateContent" payload = gemini_payload(resolved, messages, tools, kwargs) hdrs = {"Content-Type": "application/json", **headers} - params = {"key": key} if key else {} + params: Dict[str, str] = {} async with make_client(timeout=DEFAULT_TIMEOUT, verify=VERIFY_SSL) as client: resp = await client.post(url, json=payload, headers=hdrs, params=params) @@ -215,7 +215,7 @@ async def gemini_stream( url = f"{resolved['api_base']}/v1beta/models/{resolved['route']}:streamGenerateContent" payload = gemini_payload(resolved, messages, tools, kwargs) hdrs = {"Content-Type": "application/json", **headers} - params = {"key": key, "alt": "sse"} if key else {"alt": "sse"} + params = {"alt": "sse"} has_seen_tool_calls = False _stream_state["tool_indices"] = {} diff --git a/cecli/helpers/llms/providers/gemini.py b/cecli/helpers/llms/providers/gemini.py index a1515153b0c..cbe49c2a4b5 100644 --- a/cecli/helpers/llms/providers/gemini.py +++ b/cecli/helpers/llms/providers/gemini.py @@ -1,11 +1,10 @@ """Gemini provider adapter for the llms package. -Gemini authenticates via the ``key`` query parameter (or ``X-Goog-Api-Key`` -header), NOT via an ``Authorization: Bearer`` header. The base -:class:`ProviderAdapter` adds a Bearer header whenever a key is present, which -Google rejects with 401 for API keys, so this adapter overrides -:meth:`build_headers` to skip it (the domain adapter passes ``key`` as a query -param itself). +Gemini authenticates via the ``x-goog-api-key`` header, NOT via an +``Authorization: Bearer`` header. The base :class:`ProviderAdapter` adds a +Bearer header whenever a key is present, which Google rejects with 401 for API +keys, so this adapter overrides :meth:`build_headers` to set ``x-goog-api-key`` +instead. """ from __future__ import annotations @@ -16,7 +15,7 @@ class GeminiProvider(ProviderAdapter): - """Gemini: key via query param; no Authorization header.""" + """Gemini: key via x-goog-api-key header; no Authorization header.""" provider: str = "gemini" @@ -27,10 +26,12 @@ def build_headers( family: str, headers: Dict[str, str], ) -> Dict[str, str]: - """Return headers without an Authorization header (key is a query param).""" + """Return headers with x-goog-api-key set instead of Authorization.""" merged = dict(headers) merged.setdefault("Content-Type", "application/json") + if key: + merged["x-goog-api-key"] = key return merged diff --git a/cecli/helpers/monorepo/__init__.py b/cecli/helpers/monorepo/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/cecli/helpers/monorepo/config.py b/cecli/helpers/monorepo/config.py deleted file mode 100644 index 6fb4fdb29c2..00000000000 --- a/cecli/helpers/monorepo/config.py +++ /dev/null @@ -1,174 +0,0 @@ -import json -from pathlib import Path -from typing import Any, Dict, Optional - -import yaml - -from cecli.decoding import safe_open - - -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 safe_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 safe_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_file(path: Path) -> Dict[str, Any]: - """Load and validate a repo-local ``.cecli.workspaces.yml`` file.""" - from cecli.helpers.monorepo.local_workspace import load_workspace_file - - config = load_workspace_file(path) - validate_config(config) - return config - - -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: - """ - Validate workspace config shape. - - Each project must have a ``name`` and exactly one of ``path`` (local git - root) or ``repo`` (clone URL). At most one project may set ``primary: true``. - """ - 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() - primary_count = 0 - for project in config["projects"]: - if "name" not in project: - raise ValueError("Each project must have a 'name'") - has_path = bool(project.get("path")) - has_repo = bool(project.get("repo")) - if has_path == has_repo: - raise ValueError( - f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" - ) - if project.get("primary"): - primary_count += 1 - if project["name"] in project_names: - raise ValueError(f"Duplicate project name: {project['name']}") - project_names.add(project["name"]) - if primary_count > 1: - raise ValueError("Only one project may be marked primary: true") - - -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 diff --git a/cecli/helpers/monorepo/project.py b/cecli/helpers/monorepo/project.py deleted file mode 100644 index 516208a2296..00000000000 --- a/cecli/helpers/monorepo/project.py +++ /dev/null @@ -1,90 +0,0 @@ -import subprocess -from pathlib import Path -from typing import Any, Dict - -from cecli.helpers.monorepo.worktree import WorktreeManager - - -class Project: - def __init__(self, workspace_path: Path, config: Dict[str, Any]): - self.workspace_path = workspace_path - self.config = config - self.name = config["name"] - self.repo_url = config["repo"] - self.ignore_file = config.get("ignore") - self.base_path = workspace_path / self.name - self.main_path = self.base_path / "main" - - def initialize(self) -> None: - """Clone the repository and setup worktrees.""" - if not self.main_path.exists(): - self.main_path.mkdir(parents=True, exist_ok=True) - - target_branch = self.config.get("branch") - use_current = self.config.get("use_current_branch", True) - - clone_cmd = ["git", "clone", "--depth", "1"] - if target_branch and not use_current: - clone_cmd += ["--branch", target_branch] - - clone_cmd += [self.repo_url, str(self.main_path)] - - subprocess.run(clone_cmd, check=True) - - # Ensure correct branch is checked out - target_branch = self.config.get("branch") - use_current = self.config.get("use_current_branch", True) - - if target_branch and not use_current: - try: - # Check current branch - current_branch = subprocess.check_output( - ["git", "-C", str(self.main_path), "rev-parse", "--abbrev-ref", "HEAD"], - encoding="utf-8", - ).strip() - - if current_branch != target_branch: - # Try to checkout directly - res = subprocess.run( - ["git", "-C", str(self.main_path), "checkout", target_branch], - check=False, - capture_output=True, - ) - - if res.returncode != 0: - # If checkout fails, check if it exists on origin - subprocess.run( - ["git", "-C", str(self.main_path), "fetch", "origin", target_branch], - check=False, - ) - - # Try checking out the remote branch - res = subprocess.run( - [ - "git", - "-C", - str(self.main_path), - "checkout", - "-b", - target_branch, - f"origin/{target_branch}", - ], - check=False, - capture_output=True, - ) - - if res.returncode != 0: - # If it still fails, it doesn't exist on origin, so create it locally - subprocess.run( - ["git", "-C", str(self.main_path), "checkout", "-b", target_branch], - check=True, - ) - except Exception: - # Fallback for unexpected errors - pass - # Handle worktrees - worktrees_config = self.config.get("worktrees", []) - if worktrees_config: - wt_manager = WorktreeManager(self.main_path) - for wt_cfg in worktrees_config: - wt_manager.create(wt_cfg["name"], wt_cfg["branch"]) diff --git a/cecli/helpers/monorepo/workspace.py b/cecli/helpers/monorepo/workspace.py deleted file mode 100644 index 8011057c74b..00000000000 --- a/cecli/helpers/monorepo/workspace.py +++ /dev/null @@ -1,48 +0,0 @@ -import os -from pathlib import Path -from typing import Any, Dict - -from cecli.decoding import safe_open -from cecli.helpers.monorepo.project import Project - - -class WorkspaceManager: - def __init__(self, workspace_name: str, config: Dict[str, Any]): - self.name = workspace_name - self.config = config - self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}")) - - def exists(self) -> bool: - """Check if workspace directory exists""" - return self.path.exists() - - def initialize(self) -> None: - """Create workspace structure and clone repositories""" - self.path.mkdir(parents=True, exist_ok=True) - - projects_config = self.config.get("projects", []) - for proj_cfg in projects_config: - project = Project(self.path, proj_cfg) - project.initialize() - - # Copy ignore files to workspace root - for proj_cfg in projects_config: - ignore_file = proj_cfg.get("ignore") - if ignore_file: - ignore_path = Path(ignore_file).expanduser() - if ignore_path.exists(): - import shutil - - dest_path = self.path / f"{proj_cfg['name']}.ignore" - shutil.copy2(ignore_path, dest_path) - - # Write metadata - import json - - metadata_path = self.path / ".cecli-workspace.json" - with safe_open(metadata_path, "w") as f: - json.dump(self.config, f, indent=2) - - def get_working_directory(self) -> Path: - """Return workspace root path for cd""" - return self.path diff --git a/cecli/helpers/monorepo/worktree.py b/cecli/helpers/monorepo/worktree.py deleted file mode 100644 index 22be86b35f5..00000000000 --- a/cecli/helpers/monorepo/worktree.py +++ /dev/null @@ -1,21 +0,0 @@ -import subprocess -from pathlib import Path - - -class WorktreeManager: - def __init__(self, main_repo_path: Path): - self.main_repo_path = main_repo_path - self.worktrees_dir = main_repo_path.parent / "worktrees" - - def create(self, name: str, branch: str) -> None: - """Create a git worktree.""" - wt_path = self.worktrees_dir / name - if wt_path.exists(): - return - - self.worktrees_dir.mkdir(parents=True, exist_ok=True) - - subprocess.run( - ["git", "-C", str(self.main_repo_path), "worktree", "add", str(wt_path), branch], - check=True, - ) diff --git a/cecli/helpers/orchestration/environment.py b/cecli/helpers/orchestration/environment.py index 0d301da54d5..0926c11665f 100644 --- a/cecli/helpers/orchestration/environment.py +++ b/cecli/helpers/orchestration/environment.py @@ -545,9 +545,10 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non orchestration_config = agent_config.get("orchestration", {}) context = """ -The `Orchestrate` tool runs Python in a sandbox where you can call other tools programmatically. -Use it for batch or loop-heavy workflows. +The `Orchestrate` tool runs Python in a sandbox where you can script other tools programmatically. +Use it for batch, loop-heavy, and repeat-able workflows. Variables and helpers persist across calls; `state` persists across all Orchestrate calls in the session. +You may need to explore the below primitives to understand how to use the sandbox effectively. ### Primitives @@ -556,7 +557,7 @@ def build_orchestration_context_block(agent_config: dict[str, Any]) -> str | Non | `Agent.allowed_methods()` / `Agent.allowed_tools()` | List helper methods and available tools | | `Agent.get_tool(name)` | Get a tool proxy (case-insensitive; `Local--` / `{{Server Name}}--` prefixes ok) | | `await tool.call(**params)` | Run a tool; returns `{"result": [...], "errors": [...], "details": [...]}`, items with shape `{"content", "_"}` | -| `Agent.peek(result)` / `Agent.get_value(result, "path", default?)` | Inspect / extract values from tool results | +| `Agent.peek(result)` / `Agent.get_value(result, path, default?)` | Inspect / extract values from tool results. path is dot-separated string | | `Agent.resolve_regions(path, specs)` / `Agent.edit_region(path, edits)` | Resolve text boundaries once, then apply edits | | `gather(**tasks)` | Run tasks concurrently; results expose `.key` and `["key"]` | | `state` / `shared_state` | Persistent dicts; `state.get(k)` falls back to `shared_state` | diff --git a/cecli/helpers/skills.py b/cecli/helpers/skills.py index 7d6fbea31f4..2c8d11e9897 100644 --- a/cecli/helpers/skills.py +++ b/cecli/helpers/skills.py @@ -52,7 +52,7 @@ def __init__( include_list: Optional[List[str]] = None, exclude_list: Optional[List[str]] = None, initialize_list: Optional[List[str]] = None, - git_root: Optional[str] = None, + root: Optional[str] = None, coder=None, ): """ @@ -63,7 +63,7 @@ def __init__( include_list: Optional list of skill names to include (whitelist) exclude_list: Optional list of skill names to exclude (blacklist) initialize_list: Optional list of skill names to load and include on startup - git_root: Optional git root directory for relative path resolution + root: Optional base root directory for relative path resolution coder: Optional reference to the coder instance (weak reference) """ # Always include the default skills directory in the user's home @@ -71,12 +71,29 @@ def __init__( if default_skill_dir not in directory_paths: directory_paths = [default_skill_dir] + list(directory_paths) - # Resolve every path and drop exact directory duplicates. + # Local path resolution base: use root so sub-agents with an overridden + # local root still resolve project skills relative to the primary + # workspace (instead of the working directory). + local_anchor = Path(root).expanduser().resolve() if root else Path.cwd() + + # Also include the local default skills directory under the coder root + # so project-scoped skills can live in {root}/.cecli/skills alongside the + # global ~/.cecli/skills default. + if root: + local_default_skill_dir = str(local_anchor / ".cecli" / "skills") + if local_default_skill_dir not in directory_paths: + directory_paths.append(local_default_skill_dir) + + # Resolve every path relative to local_anchor and drop exact duplicates. resolved_paths = [] seen_paths = set() for p in directory_paths: try: - path = Path(p).expanduser().resolve() + candidate = Path(p).expanduser() + if candidate.is_absolute(): + path = candidate.resolve() + else: + path = (local_anchor / candidate).resolve() except Exception: continue if path in seen_paths: @@ -85,18 +102,17 @@ def __init__( resolved_paths.append(path) # Order paths: local project dirs first, then configured, home dirs last. - git_root_path = Path(git_root).expanduser().resolve() if git_root else None ordered = sorted( enumerate(resolved_paths), key=lambda item: ( - self._directory_priority(item[1], git_root_path), + self._directory_priority(item[1], local_anchor), item[0], ), ) self.directory_paths = [path for _, path in ordered] self.include_list = set(include_list) if include_list else None self.exclude_list = set(exclude_list) if exclude_list else set() - self.git_root = Path(git_root).expanduser().resolve() if git_root else None + self.root = Path(root).expanduser().resolve() if root else None self._coder_ref = weakref.ref(coder) if coder else None # Weak reference to coder instance # Cache for loaded skills @@ -134,12 +150,12 @@ def __init__( # Save initial state from config @staticmethod - def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int: + def _directory_priority(path: Path, root: Optional[Path] = None) -> int: """Return the ordering priority of a skill directory. Lower values are scanned first and therefore win by-name conflicts: - 0 - local project directory (under the git root or working directory) + 0 - local project directory (under the root or working directory) 1 - any other configured directory 2 - a home directory (including the implicit ``~/.cecli/skills`` default) """ @@ -149,7 +165,7 @@ def _directory_priority(path: Path, git_root: Optional[Path] = None) -> int: if path == (home / ".cecli" / "skills"): return 2 - local_anchor = git_root if git_root is not None else Path.cwd() + local_anchor = root if root is not None else Path.cwd() try: path.relative_to(local_anchor) return 0 @@ -834,7 +850,7 @@ def skill_summary_loader( directory_paths: List[str], include_list: Optional[List[str]] = None, exclude_list: Optional[List[str]] = None, - git_root: Optional[str] = None, + root: Optional[str] = None, ) -> str: """ High-level function to load and summarize all available skills. @@ -843,12 +859,12 @@ def skill_summary_loader( directory_paths: List of directory paths to search for skills include_list: Optional list of skill names to include (whitelist) exclude_list: Optional list of skill names to exclude (blacklist) - git_root: Optional git root directory for relative path resolution + root: Optional base root directory for relative path resolution Returns: Formatted summary of all available skills """ - manager = cls(directory_paths, include_list, exclude_list, git_root) + manager = cls(directory_paths, include_list, exclude_list, root) summaries = manager.get_all_skill_summaries() if not summaries: @@ -862,15 +878,13 @@ def skill_summary_loader( return result @staticmethod - def resolve_skill_directories( - base_paths: List[str], git_root: Optional[str] = None - ) -> List[Path]: + def resolve_skill_directories(base_paths: List[str], root: Optional[str] = None) -> List[Path]: """ Resolve skill directory paths relative to various locations. Args: base_paths: List of base directory paths - git_root: Optional git root directory + root: Optional base root directory Returns: List of resolved Path objects @@ -878,9 +892,9 @@ def resolve_skill_directories( resolved_paths = [] for base_path in base_paths: - # Try to resolve relative to git root first - if git_root and not Path(base_path).is_absolute(): - git_path = Path(git_root) / base_path + # Try to resolve relative to root first + if root and not Path(base_path).is_absolute(): + git_path = Path(root) / base_path if git_path.exists(): resolved_paths.append(git_path.resolve()) continue diff --git a/cecli/helpers/workspaces/__init__.py b/cecli/helpers/workspaces/__init__.py new file mode 100644 index 00000000000..c15daf112fd --- /dev/null +++ b/cecli/helpers/workspaces/__init__.py @@ -0,0 +1,13 @@ +"""Workspaces: local multi-project workspaces with implicit ``ws:{name}`` sub-agents. + +Replaces the former ``cecli.helpers.monorepo`` module. Workspaces are driven +entirely by a local ``.cecli.workspaces.yml`` configuration file that lists +existing git roots via ``path:`` entries. Each project automatically gets an +implicit ``ws:{project}`` sub-agent (mirroring the ``worker`` default) whose +``root`` points at that project, so workspaces can be spun up as agents. +""" + +from .subagents import register_workspace_subagents +from .workspace import WorkspaceManager + +__all__ = ["register_workspace_subagents", "WorkspaceManager"] diff --git a/cecli/helpers/workspaces/config.py b/cecli/helpers/workspaces/config.py new file mode 100644 index 00000000000..80121bc9ef2 --- /dev/null +++ b/cecli/helpers/workspaces/config.py @@ -0,0 +1,189 @@ +"""Workspace configuration loading and validation (local, path-based). + +Workspaces are defined by a local ``.cecli.workspaces.yml`` file listing +existing git roots via ``path:`` entries. There is no ``repo:``/clone mode: +every project must point at an on-disk git root. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + +from cecli.decoding import safe_open + +WORKSPACE_FILENAMES = (".cecli.workspaces.yml", ".cecli.workspaces.yaml") + + +def resolve_workspace_config(config_arg: Optional[str] = None) -> Optional[Any]: + """Resolve the raw workspace config from the same hierarchy as before: + + 1. ``config_arg`` (JSON/YAML string or path to a file) + 2. Local ``.cecli.workspaces.yml`` / ``.cecli.workspaces.yaml`` + 3. Global ``~/.cecli/workspaces.yml`` / ``.cecli/workspaces.yaml`` + """ + workspace_conf = None + + if config_arg: + candidate = Path(config_arg).expanduser() + if candidate.is_file(): + workspace_conf = _load_yaml_file(candidate) + else: + workspace_conf = _parse_workspace_string(config_arg) + + if not workspace_conf: + for name in WORKSPACE_FILENAMES: + local_path = Path(name) + if local_path.is_file(): + workspace_conf = _load_yaml_file(local_path) + if workspace_conf: + break + + if not workspace_conf: + for name in ("workspaces.yml", "workspaces.yaml"): + global_path = Path.home() / ".cecli" / name + if global_path.is_file(): + workspace_conf = _load_yaml_file(global_path) + if workspace_conf: + break + + return workspace_conf + + +def _load_yaml_file(path: Path) -> Optional[Any]: + try: + with safe_open(path, "r") as f: + loaded = yaml.safe_load(f) + except Exception: + return None + if not loaded: + return None + if isinstance(loaded, dict): + return loaded.get("workspaces") or loaded.get("workspace") or loaded + return loaded + + +def _parse_workspace_string(config_arg: str) -> Optional[Any]: + try: + loaded = json.loads(config_arg) + except (json.JSONDecodeError, TypeError): + try: + loaded = yaml.safe_load(config_arg) + except yaml.YAMLError: + return None + if isinstance(loaded, dict): + return loaded.get("workspaces") or loaded.get("workspace") or loaded + return loaded + + +def load_workspace_config_file(path: Path) -> Dict[str, Any]: + """Load and validate a repo-local ``.cecli.workspaces.yml`` file.""" + from .paths import load_workspace_file + + config = load_workspace_file(path) + validate_config(config) + return config + + +def load_workspace_config( + config_arg: Optional[str] = None, + name: Optional[str] = None, +) -> Dict[str, Any]: + """Load workspace config from the hierarchy, optionally selecting by name.""" + workspace_conf = resolve_workspace_config(config_arg) + + config: Dict[str, Any] = {} + if isinstance(workspace_conf, list): + if name: + selected = next((ws for ws in workspace_conf if ws.get("name") == name), None) + if not selected: + raise ValueError(f"Workspace '{name}' not found in configuration") + config = selected + else: + active = [ws for ws in workspace_conf if ws.get("active")] + if len(active) > 1: + names = [ws.get("name", "unknown") for ws in active] + raise ValueError(f"Multiple workspaces marked as active: {', '.join(names)}") + active_ws = active[0] if active else None + 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: + """Validate a workspace config. + + Each project must have a ``name`` and **exactly one** of ``path`` (local + git root) or ``repo`` (clone URL). At most one project may set + ``primary: true``. + """ + 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() + primary_count = 0 + for project in config["projects"]: + if "name" not in project: + raise ValueError("Each project must have a 'name'") + has_path = bool(project.get("path")) + has_repo = bool(project.get("repo")) + if not (has_path or has_repo): + raise ValueError( + f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" + ) + if has_path and has_repo: + raise ValueError( + f"Project '{project['name']}' must have exactly one of 'path' or 'repo'" + ) + if project.get("primary"): + primary_count += 1 + if project["name"] in project_names: + raise ValueError(f"Duplicate project name: {project['name']}") + project_names.add(project["name"]) + + if primary_count > 1: + raise ValueError("Only one project may be marked primary: true") + + +def workspace_layout(config: Dict[str, Any]) -> str: + """Return the workspace layout: ``clone`` if any project uses ``repo``, else ``local``. + + An explicit ``layout`` field on the workspace overrides the inference. + """ + explicit = config.get("layout") + if explicit in ("clone", "local"): + return explicit + for proj in config.get("projects") or []: + if proj.get("repo"): + return "clone" + return "local" + + +def find_active_workspace_name(config_arg: Optional[str] = None) -> Optional[str]: + """Return the active workspace name without fully resolving it.""" + workspace_conf = resolve_workspace_config(config_arg) + + if isinstance(workspace_conf, list): + active = next((ws for ws in workspace_conf if ws.get("active")), None) + if active: + return active.get("name") + if len(workspace_conf) == 1: + return workspace_conf[0].get("name") + elif isinstance(workspace_conf, dict): + return workspace_conf.get("name") + + return None diff --git a/cecli/helpers/monorepo/local_workspace.py b/cecli/helpers/workspaces/paths.py similarity index 69% rename from cecli/helpers/monorepo/local_workspace.py rename to cecli/helpers/workspaces/paths.py index 51df9b486c9..17f6000d6db 100644 --- a/cecli/helpers/monorepo/local_workspace.py +++ b/cecli/helpers/workspaces/paths.py @@ -1,18 +1,11 @@ -""" -Repo-local multi-project workspaces (``path:`` git roots). - -Cecli already supports **clone** workspaces under ``~/.cecli/workspaces/`` with -``repo:`` URLs and paths like ``{project}/main/{file}``. This module adds -**local** layout: projects point at existing directories on disk, and tracked -paths are prefixed as ``{project}/{file}`` (no ``/main/`` segment). - -Config file names (at the workspace root — usually the primary project directory): +"""Path helpers for local and clone workspaces. -- ``.cecli.workspaces.yml`` -- ``.cecli.workspaces.yaml`` +A workspace lists projects that are either: -Each project must have exactly one of ``path`` (absolute local git root) or -``repo`` (clone URL; handled by existing clone workspace code). +- **local** — an existing git root referenced by an absolute ``path:``; tracked + paths are prefixed ``{project}/{file}``. +- **clone** — a remote ``repo:`` URL cloned under ``~/.cecli/workspaces/``; + tracked paths are prefixed ``{project}/main/{file}``. """ from __future__ import annotations @@ -20,7 +13,7 @@ import json import subprocess from pathlib import Path -from typing import Any +from typing import Any, Callable import yaml @@ -61,32 +54,42 @@ def primary_project(config: dict[str, Any]) -> dict[str, Any] | None: for proj in projects: if proj.get("primary"): return proj - if len(projects) == 1: - return projects[0] return projects[0] if projects else None -def project_git_root(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None: +def project_path(workspace_root: Path, project: dict[str, Any], *, layout: str) -> Path | None: + """Resolve a project's on-disk git root for the given layout, or None.""" name = project.get("name") if not name: return None - path_val = project.get("path") - if path_val: - root = Path(str(path_val)).expanduser().resolve() - if not root.is_dir(): + + if layout == "clone": + clone_root = workspace_root / name / "main" + if not clone_root.is_dir(): return None try: subprocess.check_output( - ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + ["git", "-C", str(clone_root), "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL, ) - return root + return clone_root.resolve() except Exception: return None - if layout != "clone": + + path_val = project.get("path") + if not path_val: + return None + root = Path(str(path_val)).expanduser().resolve() + if not root.is_dir(): + return None + try: + subprocess.check_output( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + stderr=subprocess.DEVNULL, + ) + return root + except Exception: return None - clone_root = workspace_root / name / "main" - return clone_root if clone_root.is_dir() else None def project_path_prefix(project: dict[str, Any], *, layout: str) -> str: @@ -103,15 +106,19 @@ def resolve_workspace_file_path( *, layout: str, ) -> tuple[Path, Path, str] | None: - """ - Map a workspace-relative path to ``(project_git_root, absolute_file, path_in_project_repo)``. + """Map a workspace-relative path to ``(project_git_root, abs_file, path_in_repo)``. + + ``workspace_rel`` is ``{project}/{file}`` (local) or ``{project}/main/{file}`` + (clone). If the leading segment is not a project name, it resolves against + the primary project. """ rel = workspace_rel.replace("\\", "/").lstrip("/") if not rel: return None - parts = Path(rel).parts + parts = rel.split("/") if not parts: return None + projects = config.get("projects") or [] by_name = {str(p.get("name")): p for p in projects if p.get("name")} @@ -120,17 +127,17 @@ def resolve_workspace_file_path( proj = by_name.get(parts[0]) if not proj: return None - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: return None in_repo = "/".join(parts[2:]) if len(parts) > 2 else "" abs_path = git_root / in_repo if in_repo else git_root return git_root, abs_path, in_repo - # Local layout: name/rest or bare path under primary-only tree + # name/rest for local, or name/main/rest handled above if parts[0] in by_name: proj = by_name[parts[0]] - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: return None in_repo = "/".join(parts[1:]) if len(parts) > 1 else "" @@ -139,10 +146,9 @@ def resolve_workspace_file_path( primary = primary_project(config) if primary: - git_root = project_git_root(workspace_root, primary, layout=layout) + git_root = project_path(workspace_root, primary, layout=layout) if git_root: - in_repo = rel - return git_root, git_root / in_repo, in_repo + return git_root, git_root / rel, rel return None @@ -151,15 +157,15 @@ def union_tracked_files( config: dict[str, Any], *, layout: str, - ignored_file=None, + ignored_file: Callable[[str], bool] | None = None, ) -> list[str]: - """All tracked files as workspace-relative paths.""" + """All tracked files as workspace-relative paths for the given layout.""" out: list[str] = [] for proj in config.get("projects") or []: name = proj.get("name") if not name: continue - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: continue prefix = project_path_prefix(proj, layout=layout) @@ -174,7 +180,7 @@ def union_tracked_files( for line in lines: if not line.strip(): continue - rel = f"{prefix}/{line}" if prefix else line + rel = f"{prefix}/{line}" rel = rel.replace("\\", "/") if ignored_file and ignored_file(rel): continue @@ -193,7 +199,7 @@ def project_head_shas( name = proj.get("name") if not name: continue - git_root = project_git_root(workspace_root, proj, layout=layout) + git_root = project_path(workspace_root, proj, layout=layout) if not git_root: shas.append(f"{name}:unknown") continue @@ -209,25 +215,22 @@ def project_head_shas( return shas -def write_workspace_metadata(workspace_root: Path, config: dict[str, Any], *, layout: str) -> None: +def write_workspace_metadata(workspace_root: Path, config: dict[str, Any]) -> None: meta_dir = workspace_root / ".cecli" meta_dir.mkdir(parents=True, exist_ok=True) - payload = {**config, "_layout": layout} (meta_dir / ".workspace-meta.json").write_text( - json.dumps(payload, indent=2), + json.dumps(config, indent=2), encoding="utf-8", ) -def read_workspace_metadata(workspace_root: Path) -> tuple[dict[str, Any], str] | None: - legacy = workspace_root / ".cecli-workspace.json" +def read_workspace_metadata(workspace_root: Path) -> dict[str, Any] | None: modern = workspace_root / METADATA_NAME + legacy = workspace_root / ".cecli-workspace.json" path = modern if modern.is_file() else legacy if legacy.is_file() else None if not path: return None try: - data = json.loads(path.read_text(encoding="utf-8")) - layout = data.pop("_layout", "clone") - return data, layout + return json.loads(path.read_text(encoding="utf-8")) except Exception: return None diff --git a/cecli/helpers/workspaces/subagents.py b/cecli/helpers/workspaces/subagents.py new file mode 100644 index 00000000000..7dd364da936 --- /dev/null +++ b/cecli/helpers/workspaces/subagents.py @@ -0,0 +1,121 @@ +"""Implicit ``ws:{name}`` workspace sub-agents. + +When a workspace is active, each project becomes a sub-agent named +``ws:{project}``. The agent mirrors the ``worker`` default sub-agent but has +its ``root`` overridden to the project's git root and ``allow_nested_delegation`` +enabled so it can itself serve as a base for further delegations. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .config import workspace_layout +from .paths import project_path + +logger = logging.getLogger(__name__) + + +def register_workspace_subagents( + workspace_config: Dict[str, Any] | None, + workspace_root: Optional[Path | str] = None, +) -> List[str]: + """Create and register a ``ws:{name}`` sub-agent for each workspace project. + + Each project may define a ``metadata`` block that supplies its sub-agent + setup the same way a sub-agent .md file does: ``model`` / ``hooks`` / + ``auto_reap`` become the config fields, and any other keys (e.g. + ``agent-config``) are merged into the sub-agent metadata. + + ``root``, ``name`` and ``description`` are always derived from the + workspace/project definition and cannot be overridden by the metadata block. + + Returns the list of registered agent names. + """ + from cecli.helpers.agents.config import SubAgentConfig + from cecli.helpers.agents.service import AgentService + + config = workspace_config or {} + projects = config.get("projects") or [] + + # Make sure the built-in defaults (including ``worker``) are loaded so the + # workspace agents can mirror them, regardless of call ordering. + if "worker" not in AgentService.get_registry(): + AgentService.build_registry([]) + + worker = AgentService.get_registry().get("worker") + + layout = workspace_layout(config) + if workspace_root is not None: + root_base = Path(workspace_root).resolve() + elif layout == "clone": + root_base = Path(os.path.expanduser(f"~/.cecli/workspaces/{config.get('name')}")) + else: + root_base = Path(".") + + registered: List[str] = [] + for proj in projects: + name = proj.get("name") + if not name: + continue + root = project_path(root_base, proj, layout=layout) + if not root: + continue + + # A project may supply its own sub-agent setup under ``metadata``, + # matching how .md sub-agent definitions do: ``model`` / ``hooks`` / + # ``auto_reap`` map to the config fields; everything else is merged + # into the sub-agent metadata. + project_meta = dict(proj.get("metadata") or {}) + + # ``root``, ``name`` and ``description`` are always derived from the + # workspace/project definition and cannot be overridden by the metadata block. + project_meta.pop("root", None) + project_meta.pop("name", None) + project_meta.pop("description", None) + config_keys = {"model", "hooks", "auto_reap"} + + model = project_meta.get("model", worker.model if worker else None) + hooks = ( + dict(project_meta["hooks"]) + if "hooks" in project_meta + else (dict(worker.hooks) if worker else {}) + ) + auto_reap = ( + project_meta["auto_reap"] + if "auto_reap" in project_meta + else (worker.auto_reap if worker else None) + ) + + agent_name = f"ws:{name}" + metadata = dict(worker.metadata) if worker else {} + for key, value in project_meta.items(): + if key in config_keys: + continue + metadata[key] = value + metadata["root"] = str(root) + metadata["layout"] = layout + + agent_config = dict(metadata.get("agent-config") or {}) + agent_config["allow_nested_delegation"] = True + metadata["agent-config"] = agent_config + + metadata["description"] = f"Workspace sub-agent for project '{name}' at path {root}" + + agent = SubAgentConfig( + name=agent_name, + prompt=(worker.prompt if worker else ""), + model=model, + hooks=hooks, + auto_reap=auto_reap, + metadata=metadata, + ) + + AgentService.register_subagent(agent_name, agent) + registered.append(agent_name) + logger.info("Registered workspace sub-agent '%s' -> %s", agent_name, root) + + return registered diff --git a/cecli/helpers/workspaces/workspace.py b/cecli/helpers/workspaces/workspace.py new file mode 100644 index 00000000000..08e461449f7 --- /dev/null +++ b/cecli/helpers/workspaces/workspace.py @@ -0,0 +1,100 @@ +"""Workspace manager supporting clone and local layouts. + +- **clone** — projects with a ``repo:`` URL are cloned under + ``~/.cecli/workspaces/{name}/{project}/main``; the working directory is the + workspace root. +- **local** — projects point at existing on-disk git roots via ``path:``; the + working directory is the primary project's git root. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any, Dict, Optional + +from .config import workspace_layout +from .paths import primary_project, project_path + + +class WorkspaceManager: + def __init__( + self, + workspace_name: str, + config: Dict[str, Any], + root: Optional[Path | str] = None, + ): + self.name = workspace_name + self.config = config + self.layout = workspace_layout(config) + + if self.layout == "clone": + self.path = Path(os.path.expanduser(f"~/.cecli/workspaces/{workspace_name}")) + elif root is not None: + self.path = Path(root).resolve() + else: + primary = primary_project(config) + self.path = ( + Path(str(primary["path"])).expanduser().resolve() + if primary and primary.get("path") + else Path.cwd().resolve() + ) + self.root = self.path + + def exists(self) -> bool: + """Check whether the workspace root directory exists.""" + return self.path.exists() + + def initialize(self) -> None: + """Create the workspace root, clone ``repo:`` projects, and write metadata.""" + self.path.mkdir(parents=True, exist_ok=True) + + if self.layout == "clone": + for proj in self.config.get("projects") or []: + if proj.get("repo"): + self._clone_project(self.path, proj) + + from .paths import write_workspace_metadata + + write_workspace_metadata(self.path, self.config) + + def get_working_directory(self) -> Path: + """Return the workspace root (clone) or the primary project's git root (local).""" + if self.layout == "clone": + return self.path + primary = primary_project(self.config) + if primary: + root = project_path(self.path, primary, layout="local") + if root: + return root + return self.path + + def _clone_project(self, workspace_root: Path, project: Dict[str, Any]) -> None: + """Clone a ``repo:`` project under ``{workspace_root}/{name}/main``.""" + name = project.get("name") + repo_url = project.get("repo") + if not name or not repo_url: + return + + main_path = workspace_root / name / "main" + if main_path.exists(): + return + main_path.mkdir(parents=True, exist_ok=True) + + target_branch = project.get("branch") + use_current = project.get("use_current_branch", True) + + clone_cmd = ["git", "clone", "--depth", "1"] + if target_branch and not use_current: + clone_cmd += ["--branch", target_branch] + clone_cmd += [repo_url, str(main_path)] + + subprocess.run(clone_cmd, check=True) + + ignore_file = project.get("ignore") + if ignore_file: + ignore_path = Path(ignore_file).expanduser() + if ignore_path.exists(): + shutil.copy2(ignore_path, workspace_root / f"{name}.ignore") diff --git a/cecli/io.py b/cecli/io.py index caf97bacfa6..a3bf286315f 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1354,6 +1354,13 @@ async def _confirm_ask( while True: try: if self.prompt_session: + if ( + getattr( + getattr(self.prompt_session, "app", None), "_is_running", False + ) + is True + ): + return True # Call prompt_async directly instead of using input_task # This allows KeyboardInterrupt to propagate properly res = await self.prompt_session.prompt_async(question) diff --git a/cecli/main.py b/cecli/main.py index 262f7ab1646..dd40d83b99e 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -627,7 +627,7 @@ async def main_async( from cecli.mcp import McpServerManager, load_mcp_servers from cecli.models import ModelSettings from cecli.onboarding import offer_openrouter_oauth, select_default_model - from cecli.repo import GitRepo, GitRepoProxy + from cecli.repo import GitRepoProxy from cecli.report import report_uncaught_exceptions, set_args_error_data from cecli.versioncheck import check_version from cecli.watch import FileWatcher @@ -689,13 +689,14 @@ async def main_async( args, unknown = parser.parse_known_args(argv) os.unlink(_tmp_cfg) - uses_workspace = False if args.workspaces or args.workspace_name: - from cecli.helpers.monorepo.config import ( + from cecli.helpers.workspaces.config import ( find_active_workspace_name, load_workspace_config, + workspace_layout, ) - from cecli.helpers.monorepo.workspace import WorkspaceManager + from cecli.helpers.workspaces.subagents import register_workspace_subagents + from cecli.helpers.workspaces.workspace import WorkspaceManager # Interpolate environment variables in the workspaces argument if args.workspaces: @@ -705,14 +706,17 @@ async def main_async( ws_name = args.workspace_name or find_active_workspace_name(ws_config_arg) if ws_name: config = load_workspace_config(ws_config_arg, name=ws_name) - workspace_manager = WorkspaceManager(ws_name, config) - if not workspace_manager.exists(): - workspace_manager.initialize() + # Clone workspaces must be materialised so the implicit ws:{name} + # sub-agents can point their roots at the cloned checkouts. The + # primary agent's root is left unchanged: workspaces are just + # sub-agents with overridden roots. + if workspace_layout(config) == "clone": + wm = WorkspaceManager(ws_name, config) + if not wm.exists(): + wm.initialize() - os.chdir(workspace_manager.get_working_directory()) - git_root = get_git_root() - uses_workspace = True + register_workspace_subagents(config) if git_root: git_conf = Path(git_root) / conf_fname @@ -720,26 +724,6 @@ async def main_async( all_config_paths.append(str(git_conf)) cecli_conf_yml_files.append(str(git_conf)) - # ── Re-merge if workspace changed git_root ───────────────────────── - if uses_workspace: - merged_config = config_utils.read_and_merge_all_configs( - all_config_paths, conf_yml_files, cecli_conf_yml_files - ) - - _tmp_fd, _tmp_cfg = tempfile.mkstemp(suffix=".yml", prefix="cecli_merged_") - os.close(_tmp_fd) - - with safe_open(_tmp_cfg, "w") as f: - yaml.dump(merged_config, f) - - parser = get_parser([_tmp_cfg], git_root) - args, unknown = parser.parse_known_args(argv) - - # Re-load dotenv files in case the new git_root has an env_file - loaded_dotenvs = load_dotenv_files(git_root, args.env_file, args.encoding) - os.unlink(_tmp_cfg) - args, unknown = parser.parse_known_args(argv) - set_args_error_data(args) # Override the module-level default encoding with the resolved config value @@ -1004,7 +988,7 @@ def get_io(pretty): return await main_async(argv, input, output, right_repo_root, return_coder=return_coder) if (args.check_update or args.upgrade) and not args.just_check_update and not suppress_pre_init: - await check_version(pre_init_io, verbose=args.verbose) + await check_version(pre_init_io, verbose=args.verbose, upgrade=args.upgrade) elif args.just_check_update: update_available = await check_version(pre_init_io, just_check=True, verbose=args.verbose) return await graceful_exit(None, 0 if not update_available else 1) @@ -1185,22 +1169,21 @@ def get_io(pretty): repo = None if args.git: try: - repo = GitRepoProxy( - GitRepo( - io, - fnames, - git_dname, - args.cecli_ignore, - models=main_model.commit_message_models(), - attribute_author=args.attribute_author, - attribute_committer=args.attribute_committer, - attribute_commit_message_author=args.attribute_commit_message_author, - attribute_commit_message_committer=args.attribute_commit_message_committer, - commit_prompt=args.commit_prompt, - subtree_only=args.subtree_only, - git_commit_verify=args.git_commit_verify, - attribute_co_authored_by=args.attribute_co_authored_by, - ) + repo = GitRepoProxy.for_root( + None, + io, + fnames=fnames, + git_dname=git_dname, + cecli_ignore_file=args.cecli_ignore, + models=main_model.commit_message_models(), + attribute_author=args.attribute_author, + attribute_committer=args.attribute_committer, + attribute_commit_message_author=args.attribute_commit_message_author, + attribute_commit_message_committer=args.attribute_commit_message_committer, + commit_prompt=args.commit_prompt, + subtree_only=args.subtree_only, + git_commit_verify=args.git_commit_verify, + attribute_co_authored_by=args.attribute_co_authored_by, ) except FileNotFoundError: pass diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 108b8ea73c3..c1c29918f97 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -68,7 +68,14 @@ def __init__(self, server_config, io=None, verbose=False): # concurrent disconnect on the same loop never blocks the loop thread. self._cleanup_lock: threading.Lock = threading.Lock() self._disconnecting = False + # The session (and its transport context) is owned by a single task so + # the AnyIO task group inside the transport is entered and exited on the + # same task, no matter which task calls disconnect(). self.exit_stack = AsyncExitStack() + self._cancel_keepalive_on_close = True + self._session_task: asyncio.Task | None = None + self._session_ready: asyncio.Future | None = None + self._shutdown_event: asyncio.Event | None = None @property def is_connected(self) -> bool: @@ -78,16 +85,21 @@ def is_connected(self) -> bool: async def connect(self): """Connect to the MCP server and return the session. - If a session is already active, returns the existing session. - Otherwise, establishes a new connection and initializes the session. + If a session is already active, returns the existing session. Otherwise, + establishes a new connection and initializes the session. + + The session is owned by a dedicated task that both opens and closes the + transport, so the AnyIO task group inside the transport context is always + entered and exited on the same task, regardless of which task later calls + disconnect(). Returns: ClientSession: The active session if mcp is not disabled """ current_loop = asyncio.get_running_loop() if self.session is not None: - # Event loop affinity check: streams from stdio_client() are bound - # to the loop that created them. Reconnect if the loop changed. + # Event loop affinity check: streams from the transport are bound to + # the loop that created them. Reconnect if the loop changed. if self._connection_loop is current_loop: if self.verbose and self.io: self.io.tool_output(f"Using existing session for MCP server: {self.name}") @@ -99,51 +111,57 @@ async def connect(self): if self.verbose and self.io: self.io.tool_output(f"Establishing new connection to MCP server: {self.name}") - command = self.config["command"] - - env = os.environ.copy() - if self.config.get("env"): - env.update(self.config["env"]) - - server_params = StdioServerParameters( - command=command, - args=self.config.get("args"), - env=env, - ) + self._connection_loop = current_loop + self._shutdown_event = asyncio.Event() + self._session_ready = current_loop.create_future() + self._session_task = asyncio.create_task(self._run_session()) try: - os.makedirs(".cecli/logs/", exist_ok=True) - with safe_open(".cecli/logs/mcp-errors.log", "w") as err_file: - stdio_transport = await self.exit_stack.enter_async_context( - stdio_client(server_params, errlog=err_file) - ) - read, write = stdio_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - self._connection_loop = current_loop - return session - except Exception as e: - logging.error(f"Error initializing server {self.name}: {e}") + session = await self._session_ready + except BaseException: await self.disconnect() raise - async def disconnect(self): - """ - Disconnect from the MCP server and clean up resources. + return session + + async def disconnect(self, cancel_keepalive: bool = True): + """Disconnect from the MCP server and clean up resources. Idempotent and safe to call from any task or thread: only one caller performs the teardown; concurrent callers (possibly on another event loop) return immediately once the session is marked gone. + + The transport is closed on the session owner task (via a shutdown + signal), so the AnyIO task group inside the transport's async context is + exited on the same task that entered it. """ with self._cleanup_lock: if self._disconnecting: self.session = None return self._disconnecting = True + self._cancel_keepalive_on_close = cancel_keepalive try: - await self.exit_stack.aclose() + task = self._session_task + if task is not None and not task.done(): + # Retire this task as owner before signalling, so an owner task on + # another loop won't clear state a newer session has taken over. + self._session_task = None + if self._shutdown_event is not None: + self._shutdown_event.set() + + try: + await asyncio.wait_for(task, timeout=15) + except asyncio.TimeoutError: + task.cancel() + except RuntimeError: + # Session task lives on a different loop; it is torn down there. + pass + + elif task is None: + await self._close_session(cancel_keepalive) + await self.exit_stack.aclose() except (asyncio.CancelledError, RuntimeError, GeneratorExit): # Expected during shutdown - anyio cancel scopes don't play # well with asyncio teardown. Resources are still cleaned up. @@ -152,6 +170,9 @@ async def disconnect(self): logging.error(f"Error during cleanup of server {self.name}: {e}") finally: self.session = None + self._connection_loop = None + self._session_ready = None + self._shutdown_event = None self._disconnecting = False async def reconnect(self): @@ -196,6 +217,109 @@ def is_session_expired_error(exc): return False + async def _open_session(self): + """Open the MCP transport and initialize the session. + + All context managers are entered into ``self.exit_stack`` and the active + session is stored on ``self.session``. This runs on the session owner + task (see ``_run_session``). Subclasses override this for + transport-specific setup. + + Returns: + ClientSession: The initialized session + """ + command = self.config["command"] + + env = os.environ.copy() + if self.config.get("env"): + env.update(self.config["env"]) + + server_params = StdioServerParameters( + command=command, + args=self.config.get("args"), + env=env, + ) + + os.makedirs(".cecli/logs/", exist_ok=True) + with safe_open(".cecli/logs/mcp-errors.log", "w") as err_file: + stdio_transport = await self.exit_stack.enter_async_context( + stdio_client(server_params, errlog=err_file) + ) + read, write = stdio_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + + return session + + async def _close_session(self, cancel_keepalive: bool = True): + """Tear down session-specific resources. + + Always runs on the session owner task, immediately before the transport + context is closed. Subclasses override to clean up extra state. + """ + return None + + async def _run_session(self): + """Own the transport context for the lifetime of the session. + + Opens the transport and session, then blocks until a shutdown is + requested, then closes the transport. Both the open and the close happen + in this single task so the AnyIO task group inside the transport context + (for example ``stdio_client``) is entered and exited on the same task. + + Even when opening fails partway, any contexts already pushed onto + ``self.exit_stack`` are still closed here, so nothing leaks. + """ + # This session owns its own transport stack so an older owner task that + # is still winding down can never close a newer session's contexts. + exit_stack = AsyncExitStack() + self.exit_stack = exit_stack + + try: + try: + session = await self._open_session() + except BaseException as exc: + if not self._session_ready.done(): + if isinstance(exc, asyncio.CancelledError): + # Deliver cancellation as a future cancellation, not as an + # exception value, so a connect() caller that never awaits + # the future won't trigger a "Future exception was never + # retrieved" warning. + self._session_ready.cancel() + else: + logging.error(f"Error initializing server {self.name}: {exc}") + self._session_ready.set_exception(exc) + + return + + if not self._session_ready.done(): + self._session_ready.set_result(session) + + await self._shutdown_event.wait() + except BaseException: + pass + finally: + try: + await self._close_session(self._cancel_keepalive_on_close) + except asyncio.CancelledError: + pass + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + try: + await exit_stack.aclose() + except (asyncio.CancelledError, RuntimeError, GeneratorExit): + pass + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + # Only the current owner may clear shared state; if a newer session + # has taken over, leave its session/loop fields alone. + if self._session_task is asyncio.current_task(): + self.session = None + self._connection_loop = None + class HttpBasedMcpServer(McpServer): """Base class for HTTP-based MCP servers (HTTP streaming and SSE).""" @@ -288,66 +412,48 @@ def _create_transport(self, url, http_client): """ raise NotImplementedError("Subclasses must implement _create_transport") - async def connect(self): - current_loop = asyncio.get_running_loop() - if self.session is not None: - if self._connection_loop is current_loop: - if self.verbose and self.io: - self.io.tool_output(f"Using existing session for {self.name}") - return self.session - if self.verbose and self.io: - self.io.tool_output(f"Reconnecting {self.name} (event loop changed)") - await self.disconnect() - - if self.verbose and self.io: - self.io.tool_output(f"Establishing new connection to {self.name}") - - try: - url = self.config.get("url") - headers = self.config.get("headers", {}) - - oauth_provider = None - if not headers: - oauth_provider = await self._create_oauth_provider() - - http_client_cls = _get_http_client_module().AsyncClient - http_client = await self.exit_stack.enter_async_context( - http_client_cls( - auth=oauth_provider, - follow_redirects=True, - headers=headers, - timeout=30, - ) + async def _open_session(self): + url = self.config.get("url") + headers = self.config.get("headers", {}) + + oauth_provider = None + if not headers: + oauth_provider = await self._create_oauth_provider() + + http_client_cls = _get_http_client_module().AsyncClient + http_client = await self.exit_stack.enter_async_context( + http_client_cls( + auth=oauth_provider, + follow_redirects=True, + headers=headers, + timeout=30, ) - self._http_client = http_client + ) + self._http_client = http_client - transport = await self.exit_stack.enter_async_context( - self._create_transport(url, http_client=http_client) - ) + transport = await self.exit_stack.enter_async_context( + self._create_transport(url, http_client=http_client) + ) - read, write = _unpack_transport(transport) + read, write = _unpack_transport(transport) - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - await self.start_keepalive() - self._connection_loop = current_loop + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session - if oauth_provider is not None and oauth_provider.context.oauth_metadata: - token_endpoint = oauth_provider._get_token_endpoint() - server_info = get_mcp_oauth_token(self.name) - if "client_info" not in server_info: - server_info["client_info"] = {} + await self.start_keepalive() - server_info["client_info"]["token_endpoint"] = token_endpoint + if oauth_provider is not None and oauth_provider.context.oauth_metadata: + token_endpoint = oauth_provider._get_token_endpoint() + server_info = get_mcp_oauth_token(self.name) + if "client_info" not in server_info: + server_info["client_info"] = {} - save_mcp_oauth_token(self.name, server_info) + server_info["client_info"]["token_endpoint"] = token_endpoint - return session - except Exception as e: - logging.error(f"Error initializing {self.name}: {e}") - await self.disconnect() - raise + save_mcp_oauth_token(self.name, server_info) + + return session async def start_keepalive(self): """Start the background keepalive loop if configured.""" @@ -459,41 +565,21 @@ async def reconnect(self): f"Reconnection attempt {attempt} failed for {self.name}: {e}" ) - async def disconnect(self, cancel_keepalive: bool = True): - """ - Disconnect from the MCP server and clean up resources. + async def _close_session(self, cancel_keepalive: bool = True): + if cancel_keepalive and self._keepalive_task: + self._keepalive_task.cancel() - Idempotent and safe to call from any task or thread: only one caller - performs the teardown; concurrent callers (possibly on another event - loop) return immediately once the session is marked gone. - """ - with self._cleanup_lock: - if self._disconnecting: - self.session = None - return - self._disconnecting = True + try: + await asyncio.wait_for(self._keepalive_task, timeout=15) + except asyncio.CancelledError: + pass - try: - if cancel_keepalive and self._keepalive_task: - self._keepalive_task.cancel() - try: - await asyncio.wait_for(self._keepalive_task, timeout=15) - except asyncio.CancelledError: - pass - logger.info(f"Keepalive task stopped for {self.name}") - if hasattr(self, "_oauth_shutdown"): - self._oauth_shutdown() - await self.exit_stack.aclose() - except (asyncio.CancelledError, RuntimeError, GeneratorExit): - # Expected during shutdown - anyio cancel scopes don't play - # well with asyncio teardown. Resources are still cleaned up. - pass - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - finally: - self.session = None - self._http_client = None - self._disconnecting = False + logger.info(f"Keepalive task stopped for {self.name}") + + if hasattr(self, "_oauth_shutdown"): + self._oauth_shutdown() + + self._http_client = None class HttpStreamingServer(HttpBasedMcpServer): @@ -507,32 +593,17 @@ def _create_transport(self, url, http_client): class SseServer(McpServer): """SSE (Server-Sent Events) MCP server using mcp.client.sse_client.""" - async def connect(self): - current_loop = asyncio.get_running_loop() - if self.session is not None: - if self._connection_loop is current_loop: - logging.info(f"Using existing session for SSE MCP server: {self.name}") - return self.session - logging.info(f"Reconnecting SSE MCP server {self.name} (event loop changed)") - await self.disconnect() + async def _open_session(self): + url = self.config.get("url") + headers = self.config.get("headers", {}) - logging.info(f"Establishing new connection to SSE MCP server: {self.name}") - try: - url = self.config.get("url") - headers = self.config.get("headers", {}) - sse_transport = await self.exit_stack.enter_async_context( - sse_client(url, headers=headers) - ) - read, write = sse_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - self._connection_loop = current_loop - return session - except Exception as e: - logging.error(f"Error initializing SSE server {self.name}: {e}") - await self.disconnect() - raise + sse_transport = await self.exit_stack.enter_async_context(sse_client(url, headers=headers)) + read, write = sse_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + + return session class LocalServer(McpServer): diff --git a/cecli/repo.py b/cecli/repo.py index 7d052f58cc1..c0481051427 100644 --- a/cecli/repo.py +++ b/cecli/repo.py @@ -56,6 +56,20 @@ def set_git_env(var_name, value, original_value): del os.environ[var_name] +def _close_git_repo(repo: "GitRepo") -> None: + """Safely close a GitRepo that was just opened but will not be used. + + Prevents leaking the underlying ``git.Repo`` file handle when + ``GitRepoProxy.for_root`` discovers an already-cached repository. + """ + try: + with repo._git_lock: + if getattr(repo, "repo", None) is not None: + repo.repo.close() + except Exception: + pass + + class CommitInfo: """ Data-only container for extracted commit information. @@ -121,14 +135,7 @@ def __init__( self.subtree_only = subtree_only self.git_commit_verify = git_commit_verify self.ignore_file_cache = {} - self.is_workspace = False - self.workspace_path = None - self.workspace_config = {} - self.workspace_layout = "clone" - self.workspace_ignore_specs = {} - self.workspace_ignore_ts = {} self._git_lock = threading.RLock() - # Workspace detection and config loading occurs later in __init__ if git_dname: check_fnames = [git_dname] @@ -159,36 +166,11 @@ def __init__( if num_repos == 0: raise FileNotFoundError if num_repos > 1: - from cecli.helpers.monorepo.config import load_workspace_config_file - from cecli.helpers.monorepo.local_workspace import ( - find_workspace_config_file, - ) - - ws_file = find_workspace_config_file(Path(repo_paths[0])) - if not ws_file: - self.io.tool_error( - "Files are in different git repos. Add a .cecli.workspaces.yml at a" - " common ancestor with path: entries for each project." - ) - raise FileNotFoundError - self.workspace_config = load_workspace_config_file(ws_file) - primary = next( - (p for p in self.workspace_config.get("projects", []) if p.get("primary")), - None, + self.io.tool_error( + "Files are in different git repos. Each coder operates on a single base path." ) - if primary and primary.get("path"): - self._init_repo_path = str(Path(str(primary["path"])).expanduser().resolve()) - else: - self._init_repo_path = str(Path(repo_paths[0]).resolve()) - else: - self._init_repo_path = repo_paths.pop() - - # Detect if we're in a workspace - self.workspace_path = self._detect_workspace_path(self._init_repo_path) - if self.workspace_path: - self.is_workspace = True - self._load_workspace_config() - self.refresh_cecli_ignore() + raise FileNotFoundError + self._init_repo_path = repo_paths.pop() self.init_repo() if cecli_ignore_file: @@ -200,73 +182,11 @@ def init_repo(self): self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) self.root = utils.safe_abs_path(self.repo.working_tree_dir) - if self.is_workspace: - self.root = self.workspace_path - try: self.repo.head.commit # just access to check for errors, discard except ANY_GIT_ERROR: - if not self.is_workspace: - self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) - self.root = utils.safe_abs_path(self.repo.working_tree_dir) - - def _load_workspace_config(self) -> None: - from cecli.helpers.monorepo.config import ( - load_workspace_config, - load_workspace_config_file, - ) - from cecli.helpers.monorepo.local_workspace import ( - find_workspace_config_file, - read_workspace_metadata, - write_workspace_metadata, - ) - - ws_file = find_workspace_config_file(Path(self.workspace_path)) - if ws_file: - self.workspace_layout = "local" - self.workspace_config = load_workspace_config_file(ws_file) - write_workspace_metadata( - Path(self.workspace_path), self.workspace_config, layout="local" - ) - return - meta = read_workspace_metadata(Path(self.workspace_path)) - if meta: - self.workspace_config, self.workspace_layout = meta - return - self.workspace_layout = "clone" - try: - self.workspace_config = load_workspace_config(name=Path(self.workspace_path).name) - except Exception: - self.workspace_config = {} - - def _detect_workspace_path(self, start_path: str): - """Check if current directory is within a workspace""" - from cecli.helpers.monorepo.local_workspace import find_workspace_config_file - - current = Path(start_path).resolve() - ws_file = find_workspace_config_file(current) - if ws_file: - return ws_file.parent.resolve() - - workspace_root = Path("~/.cecli/workspaces").expanduser() - - # Walk up directory tree looking for workspace root - while current != current.parent: - if workspace_root in current.parents or current == workspace_root: - # If we are inside the workspace root, the workspace is the first child of workspace_root - try: - rel = current.relative_to(workspace_root) - if rel.parts: - return workspace_root / rel.parts[0] - except (IndexError, ValueError): - pass - - # Alternative check: look for .cecli-workspace.json - if (current / ".cecli-workspace.json").exists(): - return current - - current = current.parent - return None + self.repo = git.Repo(self._init_repo_path, odbt=git.GitCmdObjectDB) + self.root = utils.safe_abs_path(self.repo.working_tree_dir) def __del__(self): with self._git_lock: @@ -343,9 +263,6 @@ async def commit(self, fnames=None, context=None, message=None, coder_edits=Fals - User commit with explicit no-committer: coder_edits=False, --no-attribute-committer -> Author=You, Committer=You """ - if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local": - return await self._commit_local_workspace(fnames, context, message, coder_edits, coder) - with self._git_lock: if not fnames and not self.repo.is_dirty(): return @@ -682,171 +599,25 @@ def get_tracked_files(self): return res - async def _commit_local_workspace( - self, fnames=None, context=None, message=None, coder_edits=False, coder=None - ): - from collections import defaultdict - - from cecli.helpers.monorepo.local_workspace import resolve_workspace_file_path - - layout = getattr(self, "workspace_layout", "local") - config = self.workspace_config or {} - readonly = { - str(p.get("name")) - for p in config.get("projects", []) - if p.get("readonly") and p.get("name") - } - - by_root: dict[str, list[str]] = defaultdict(list) - if fnames: - for fname in fnames: - resolved = resolve_workspace_file_path( - Path(self.workspace_path), str(fname), config, layout=layout - ) - if not resolved: - continue - git_root, _abs_path, in_repo = resolved - parts = Path(str(fname)).parts - if parts and parts[0] in readonly: - continue - if in_repo: - by_root[str(git_root)].append(in_repo) - else: - for proj in config.get("projects", []): - name = proj.get("name") - if not name or name in readonly: - continue - from cecli.helpers.monorepo.local_workspace import project_git_root - - git_root = project_git_root(Path(self.workspace_path), proj, layout=layout) - if not git_root: - continue - sub = GitRepo(self.io, [str(git_root)], None) - for rel in sub.get_dirty_files() or []: - by_root[str(git_root)].append(rel) - - last = None - for root, rels in by_root.items(): - sub = GitRepo(self.io, [root], None) - last = await sub.commit( - rels, context=context, message=message, coder_edits=coder_edits, coder=coder - ) - return last - - def get_workspace_files(self): - """ - If in a workspace, return all tracked files from all projects. - Paths are relative to the workspace root. - """ - if not self.workspace_path: - return self.get_tracked_files() - - import hashlib - - layout = getattr(self, "workspace_layout", "clone") - config = self.workspace_config or {} - if not config.get("projects"): - return self.get_tracked_files() - - from cecli.helpers.monorepo.local_workspace import ( - project_head_shas, - union_tracked_files, - ) - - project_shas = project_head_shas(Path(self.workspace_path), config, layout=layout) - cache_key = hashlib.sha1(",".join(project_shas).encode()).hexdigest() - - if hasattr(self, "_workspace_files_cache"): - cached_key, cached_files = self._workspace_files_cache - if cached_key == cache_key: - return cached_files - - if layout == "local": - all_files = union_tracked_files( - Path(self.workspace_path), - config, - layout=layout, - ignored_file=self.ignored_file, - ) - self._workspace_files_cache = (cache_key, all_files) - return all_files - - import json - import subprocess - - metadata_path = self.workspace_path / ".cecli-workspace.json" - if not metadata_path.exists(): - return self.get_tracked_files() - - try: - with safe_open(metadata_path, "r") as f: - config = json.load(f) - except Exception: - return self.get_tracked_files() - - all_files = [] - for proj in config.get("projects", []): - proj_name = proj.get("name") - if not proj_name: - continue - - proj_root = self.workspace_path / proj_name / "main" - if not proj_root.exists(): - continue - - try: - res = subprocess.check_output( - ["git", "-C", str(proj_root), "ls-files"], - stderr=subprocess.DEVNULL, - encoding="utf-8", - ).splitlines() - - for f in res: - rel_path = f"{proj_name}/main/{f}" - if not self.ignored_file(rel_path): - all_files.append(rel_path) - except Exception: - continue - - self._workspace_files_cache = (cache_key, all_files) - return all_files - def normalize_path(self, path): orig_path = path res = self.normalized_path.get(orig_path) if res: return res - if self.is_workspace: - try: - # In workspace mode, try to make it relative to workspace_path first - path = str( - Path( - PurePosixPath( - (Path(self.workspace_path) / path).relative_to(self.workspace_path) - ) - ) - ) - except ValueError: - # Fallback to standard relative_to(self.root) - path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root)))) - else: - path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root)))) + path = str(Path(PurePosixPath((Path(self.root) / path).relative_to(self.root)))) self.normalized_path[orig_path] = path return path def refresh_cecli_ignore(self): - if not self.cecli_ignore_file and not self.is_workspace: + if not self.cecli_ignore_file: return current_time = time.time() if current_time - self.cecli_ignore_last_check < 1: return - if self.is_workspace: - self._refresh_workspace_ignores() - self.cecli_ignore_last_check = current_time if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file(): @@ -862,35 +633,6 @@ def refresh_cecli_ignore(self): lines, ) - def _refresh_workspace_ignores(self): - if not hasattr(self, "workspace_config") or not self.workspace_config: - return - - if not hasattr(self, "workspace_ignore_specs"): - self.workspace_ignore_specs = {} - self.workspace_ignore_ts = {} - - projects = self.workspace_config.get("projects", []) - for proj in projects: - proj_name = proj.get("name") - ignore_file = proj.get("ignore") - if not proj_name or not ignore_file: - continue - - ignore_path = self.workspace_path / f"{proj_name}.ignore" - if not ignore_path.is_file(): - continue - - mtime = ignore_path.stat().st_mtime - if mtime != self.workspace_ignore_ts.get(proj_name): - self.workspace_ignore_ts[proj_name] = mtime - self.ignore_file_cache = {} - lines = ignore_path.read_text().splitlines() - self.workspace_ignore_specs[proj_name] = pathspec.PathSpec.from_lines( - pathspec.patterns.GitWildMatchPattern, - lines, - ) - def _get_gitignore_spec(self, dir_path): """Get or create a GitIgnoreSpec for a directory, caching for performance.""" dir_path = Path(dir_path).resolve() @@ -1002,32 +744,6 @@ def ignored_file_raw(self, fname): if cwd_path not in fname_path.parents and fname_path != cwd_path: return True - if self.is_workspace: - # Check project-specific ignores - try: - fname_rel = self.normalize_path(fname) - parts = Path(fname_rel).parts - if parts: - proj_name = parts[0] - if ( - hasattr(self, "workspace_ignore_specs") - and proj_name in self.workspace_ignore_specs - ): - # Check against project-specific spec - # The spec expects paths relative to the project root (usually proj/main/) - layout = getattr(self, "workspace_layout", "clone") - if layout == "clone" and len(parts) > 2 and parts[1] == "main": - proj_rel_path = str(Path(*parts[2:])) - else: - proj_rel_path = str(Path(*parts[1:])) - - if self.workspace_ignore_specs[proj_name].match_file(proj_rel_path): - return True - # If not matched by project-specific ignore, continue to global ignore - # but don't return False yet as there might be a global .cecli.ignore - except (ValueError, IndexError): - pass - if not self.cecli_ignore_file or not self.cecli_ignore_file.is_file(): return False @@ -1070,22 +786,8 @@ def get_non_ignored_files_from_root(self): def get_repo_files(self) -> list[str]: """ - Get all relevant files from the repository, respecting workspace - structure and custom ignore rules. - - This is a unified file collection method that encapsulates the - logic for choosing the right file source depending on the repo's - configuration: - - - Workspace repos (``workspace_path`` is set) → ``get_workspace_files()`` - - Non-workspace with ``cecli_ignore`` file → ``get_non_ignored_files_from_root()`` - - Non-workspace without ``cecli_ignore`` → ``get_tracked_files()`` - - Returns: - Sorted list of root-relative file paths + Get all relevant files from the repository for this single base path. """ - if hasattr(self, "workspace_path") and self.workspace_path: - return self.get_workspace_files() if self.cecli_ignore_file and self.cecli_ignore_file.is_file(): return self.get_non_ignored_files_from_root() return self.get_tracked_files() @@ -1127,19 +829,6 @@ def path_in_repo(self, path): return self.normalize_path(path) in tracked_files def abs_root_path(self, path): - if self.is_workspace and getattr(self, "workspace_layout", "clone") == "local": - from cecli.helpers.monorepo.local_workspace import ( - resolve_workspace_file_path, - ) - - resolved = resolve_workspace_file_path( - Path(self.workspace_path), - str(path), - self.workspace_config or {}, - layout="local", - ) - if resolved: - return utils.safe_abs_path(resolved[1]) res = Path(self.root) / path return utils.safe_abs_path(res) @@ -1278,6 +967,64 @@ def __init__(self, target): max_workers=1, thread_name_prefix="git-repo" ) + _instances: dict[str, "GitRepoProxy"] = {} + + @classmethod + def for_root( + cls, + root: str | None, + io, + fnames=None, + git_dname=None, + cecli_ignore_file=None, + **kwargs, + ) -> "GitRepoProxy": + """ + Return a per-base-path ``GitRepoProxy`` singleton. + + Coders that share a base path share one proxy (and its underlying + ``git.Repo`` plus single-thread executor), so multi-repository work can + keep each repository on its own proxy while coders on the same base path + stay consistent. Coders on different base paths get independent proxies. + + If *root* is provided it is used as the registry key and to discover the + repository; otherwise it is derived from *fnames* (the same discovery the + ``GitRepo`` constructor performs). + """ + if root is None: + target = GitRepo(io, fnames, git_dname, cecli_ignore_file, **kwargs) + key = os.path.normpath(os.path.abspath(target.root)) + proxy = cls._instances.get(key) + if proxy is None: + proxy = cls(target) + cls._instances[key] = proxy + else: + _close_git_repo(target) # Discard the just-opened duplicate + return proxy + + key = os.path.normpath(os.path.abspath(root)) + proxy = cls._instances.get(key) + if proxy is None: + target = GitRepo(io, fnames or [root], git_dname, cecli_ignore_file, **kwargs) + proxy = cls(target) + cls._instances[key] = proxy + return proxy + + @classmethod + def evict(cls, root: str | None = None) -> None: + """Drop the proxy for a base path (e.g. on coder teardown).""" + if root is None: + return + key = os.path.normpath(os.path.abspath(root)) + cls._instances.pop(key, None) + + @classmethod + def reset_instances(cls) -> None: + """Reset all per-base-path proxies — used primarily in test teardown.""" + cls._instances.clear() + + # ------------------------------------------------------------------ + # Sync methods that access self.repo (the git.Repo object) # ------------------------------------------------------------------ # Sync methods that access self.repo (the git.Repo object) # ------------------------------------------------------------------ @@ -1350,9 +1097,6 @@ def get_non_ignored_files_from_root(self): def get_repo_files(self): return self._executor.submit(self._target.get_repo_files).result() - def get_workspace_files(self): - return self._executor.submit(self._target.get_workspace_files).result() - # ------------------------------------------------------------------ # Async methods – called from the main asyncio task only, and # already protected by ``_git_lock`` on the target; we do *not* @@ -1360,7 +1104,7 @@ def get_workspace_files(self): # with LLM calls (``get_commit_message``) that must stay async. # ------------------------------------------------------------------ - # commit() and _commit_local_workspace() are forwarded via __getattr__ + # commit() is forwarded via __getattr__ # ------------------------------------------------------------------ # Access to the raw ``git.Repo`` – return a sub-proxy that routes diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index bc0009b3833..2deb7bf501b 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -3,6 +3,7 @@ from . import ( _yield, + broadcast, command, delegate, edit_file, @@ -32,6 +33,7 @@ edit_file, explore_code, _yield, + broadcast, git_branch, git_diff, git_log, diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index c3577e15acf..2efe5d5f4d6 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -33,6 +33,15 @@ class Tool(BaseTool): "and returned to the parent agent." ), }, + "wait": { + "type": "integer", + "description": ( + "Optional time in seconds (between 15 and 120) to wait " + "before returning. When provided, the tool simply sleeps " + "for the specified duration and returns, " + "allowing for other tasks to proceed." + ), + }, }, "required": [], }, @@ -53,6 +62,45 @@ async def execute(cls, coder, **kwargs): response = ToolResponse(cls.NORM_NAME) if coder: + wait = kwargs.get("wait") + if wait is not None and wait != "": + if isinstance(wait, bool): + wait_seconds = 30 + else: + try: + wait_seconds = int(wait) + except (ValueError, TypeError): + wait_seconds = 30 + + wait_seconds = max(15, min(120, wait_seconds)) + + # Plain wait — sleep for the requested duration without checking + # sub-agents or marking the task as finished. + interrupt_event = coder.interrupt_event + if interrupt_event is None: + interrupt_event = ThreadSafeEvent() + + interrupt_task = asyncio.create_task(interrupt_event.wait()) + sleep_task = asyncio.create_task(asyncio.sleep(wait_seconds)) + done, pending = await asyncio.wait( + {sleep_task, interrupt_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + + for task in pending: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + if interrupt_task in done: + response.append_result(f"Wait interrupted after {wait_seconds} seconds.") + else: + response.append_result(f"Waited for {wait_seconds} seconds.") + + return response + waited_for_sub_agents = False # Check for active child sub-agents and await their tasks before finishing try: @@ -61,7 +109,9 @@ async def execute(cls, coder, **kwargs): active_tasks = [ info.generate_task for info in children - if info.generate_task is not None and not info.generate_task.done() + if not info.independent + and info.generate_task is not None + and not info.generate_task.done() ] if active_tasks: @@ -236,6 +286,13 @@ def format_output(cls, coder, mcp_server, tool_response): coder.io.tool_error("Invalid Tool JSON") return + wait = params.get("wait") + if wait: + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}Wait:{color_end}") + coder.io.tool_output(f"{wait} seconds") + coder.io.tool_output("") + summary = params.get("summary") if summary: coder.io.tool_output("") diff --git a/cecli/tools/broadcast.py b/cecli/tools/broadcast.py new file mode 100644 index 00000000000..17aae0056cb --- /dev/null +++ b/cecli/tools/broadcast.py @@ -0,0 +1,268 @@ +"""Broadcast tool - sends a message to one or more sub-agents.""" + +import time + +from cecli.tools.utils.base_tool import BaseTool +from cecli.tools.utils.helpers import ToolError +from cecli.tools.utils.output import color_markers, tool_footer, tool_header +from cecli.tools.utils.responses import ToolResponse +from cecli.tools.validations import ToolValidations + + +class Tool(BaseTool): + NORM_NAME = "broadcast" + RESULT_TYPE = "list" + VALIDATIONS = { + "targets": ["coerce_list"], + } + SCHEMA = { + "type": "function", + "function": { + "name": "Broadcast", + "description": ( + "Broadcast a message to one or more sub-agent instances. Sends the message to the " + "specified target sub-agents, or to every active sub-agent when empty/omitted. " + "The message is queued into the target's context if it is actively generating, " + "and delivered immediately if the target is idle, waking them." + ), + "parameters": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The message to broadcast to the target sub-agent(s).", + }, + "targets": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Optional array of sub-agent IDs (UUIDs, UUID prefixes, or " + "agent names) to send the message to, or 'primary' for the primary agent. " + "Set as an empty string to broadcast to all active sub-agents." + ), + }, + }, + "required": ["message"], + }, + }, + } + + @classmethod + async def execute(cls, coder, **kwargs): + """Broadcast a message to one or more sub-agents. + + For each target sub-agent, the message is either queued into its + conversation (if it is actively generating) or used to start a fresh + generate task (if it is idle). When no targets are specified, the + message is broadcast to every active sub-agent except the originator. + + Args: + coder: The coder instance invoking the tool (the originator). + message: The message to broadcast. + targets: Optional list of sub-agent IDs. + + Returns: + ToolResponse with a per-target delivery summary. + """ + from cecli.helpers.agents.service import AgentService + + response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE) + + message = kwargs.get("message") + if message is None or not str(message).strip(): + response.append_error("'message' parameter must be a non-empty string.") + return response + message = str(message).strip() + + targets = kwargs.get("targets") + if targets is not None and not isinstance(targets, list): + response.append_error("'targets' parameter must be an array of sub-agent IDs.") + return response + + agent_service = AgentService.get_instance(coder) + originator_uuid = str(coder.uuid) + + target_infos, errors = cls._collect_targets(agent_service, targets, originator_uuid) + + for error in errors: + response.append_error(error) + + delivered = [] + delivery_errors = [] + for info in target_infos: + try: + mode = cls._deliver(info, message, agent_service, coder) + delivered.append((info, mode)) + except Exception as exc: + delivery_errors.append(f"Broadcast to '{cls._target_label(info)}' failed: {exc}") + + for info, mode in delivered: + response.append_result(f"{cls._target_label(info)}: {mode}") + + for error in delivery_errors: + response.append_error(error) + + if not delivered and not errors and not delivery_errors: + response.append_result("No targets to broadcast to.") + + return response + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + """Format output for the Broadcast tool - show the message and targets.""" + color_start, color_end = color_markers(coder) + + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + + try: + params = ToolValidations.validate_params( + tool_response.function.arguments, cls.VALIDATIONS, cls.SCHEMA + ) + except ToolError: + coder.io.tool_error("Invalid Tool JSON") + return + + message = params.get("message", "") + targets = params.get("targets", []) + + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}message:{color_end}") + coder.io.tool_output(message) + coder.io.tool_output("") + if targets: + coder.io.tool_output( + f"{color_start}targets:{color_end} {', '.join(str(t) for t in targets)}" + ) + else: + coder.io.tool_output(f"{color_start}targets:{color_end} All") + + tool_footer(coder=coder, tool_response=tool_response, params=params) + + @classmethod + def _collect_targets(cls, service, targets, originator_uuid): + """Return ``(target_infos, errors)`` for the broadcast. + + When ``targets`` is empty, returns every active sub-agent except the + originator. When ``targets`` is provided, each entry is resolved by + UUID, UUID prefix, or agent name (including ``primary`` for the primary + agent); unknown IDs produce an error. + """ + from cecli.helpers.agents.service import SubAgentInfo, SubAgentStatus + + if not targets: + infos = [ + info + for info in service.sub_agents.values() + if str(info.coder.uuid) != originator_uuid + and info.status not in (SubAgentStatus.ERROR,) + ] + return infos, [] + + infos = [] + errors = [] + for target in targets: + info = cls._resolve_target(service, target) + if info is None: + errors.append(f"Unknown sub-agent target '{target}'.") + continue + target_uuid = info.coder.uuid if isinstance(info, SubAgentInfo) else info.uuid + if str(target_uuid) != originator_uuid: + infos.append(info) + + return infos, errors + + @staticmethod + def _resolve_target(service, target): + """Resolve a target sub-agent by UUID, UUID prefix, or agent name. + + The primary agent is also resolvable by its name (``primary``) or UUID + so sub-agents can broadcast a message back to their parent. + """ + target = str(target).strip() + if not target: + return None + + info = service.sub_agents.get(target) + if info is not None: + return info + + for uuid, candidate in service.sub_agents.items(): + if uuid.startswith(target): + return candidate + + for candidate in service.sub_agents.values(): + if candidate.name == target: + return candidate + + primary_coder = service.coder + primary_uuid = str(getattr(primary_coder, "uuid", "")) + if primary_coder is not None and (target == "primary" or target == primary_uuid): + return primary_coder + + return None + + @staticmethod + def _deliver(info, message, agent_service, sender_coder): + """Queue, wake, or start a generate task for a single target. + + When the target is the primary agent, ``AgentService.wake_primary()`` is + used: if the primary is actively generating the message is queued into + its conversation, otherwise it is woken via its per-coder input queue + (the primary does not use the sub-agent generate-task architecture). + + When the target is a sub-agent that is still generating, the message is + queued into its conversation; an idle sub-agent gets a fresh generate + task via ``AgentService.start_generate_task()``. + + The message is prefixed with the sender's identity so the target + sub-agent can see who broadcast it. + + Returns a short mode string describing how the message was delivered. + """ + from cecli.helpers.agents.service import SubAgentInfo + from cecli.helpers.conversation import ConversationService, MessageTag + from cecli.helpers.coroutines import is_active + + sender_name = agent_service.get_agent_name(sender_coder) or "primary" + sender_uuid = str(sender_coder.uuid) + message = ( + "\n" + f"[Message Sent from Agent {sender_name} ({sender_uuid})]\n" + "You may respond with the `Broadcast` tool if the message " + "is relevant to you\n\n" + f"{message}" + "" + ) + + if not isinstance(info, SubAgentInfo): + # Primary-agent target. The primary does not use the sub-agent + # generate-task architecture — if it is idle we must wake it via + # its per-coder input queue (same strategy as on_input_area_submit()). + return agent_service.wake_primary(info, message) + + if is_active(info.generate_task): + ConversationService.get_manager(info.coder).queue_message( + message_dict={ + "role": "user", + "content": message, + }, + tag=MessageTag.CUR, + hash_key=("broadcast", str(info.coder.uuid), str(time.monotonic_ns())), + ) + return "queued" + + agent_service.start_generate_task(info, message) + return "started" + + @staticmethod + def _target_label(target): + """Return a display label for a broadcast target. + + Sub-agents are labelled ``name (uuid)``; the primary agent is labelled + ``primary (uuid)``. + """ + from cecli.helpers.agents.service import SubAgentInfo + + if isinstance(target, SubAgentInfo): + return f"{target.name} ({target.coder.uuid})" + return f"primary ({target.uuid})" diff --git a/cecli/tools/command.py b/cecli/tools/command.py index 4359003e57a..828eea3dfcc 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/command.py @@ -226,7 +226,7 @@ async def _get_confirmation(cls, coder, command_string, background): return True # Previously approved for session # Previously declined - skip session question, continue to normal confirmation - if coder.skip_cli_confirmations: + if coder.skip_cli_confirmations or getattr(coder.args, "yes_always_commands", False): return True # Check if command matches any allowed_commands patterns diff --git a/cecli/tools/edit_file.py b/cecli/tools/edit_file.py index edd7f24f560..a2f792f958b 100644 --- a/cecli/tools/edit_file.py +++ b/cecli/tools/edit_file.py @@ -61,8 +61,8 @@ class Tool(BaseTool): f"duplicate lines by their hashed prefix (e.g., '{HASH_DELIMITER}WecX{HASH_DELIMITER}'); use " "'@000' for empty files. Identifiers track content, so edits can re-prefix identical lines " "elsewhere — re-read the file after editing for fresh identifiers. Multiple edits to one " - "file are applied bottom-to-top; overlapping or contained ranges are merged or rejected " - "automatically." + "file are applied bottom-to-top automatically; overlapping or contained ranges are merged " + "or rejected automatically." ), "parameters": { "type": "object", @@ -94,7 +94,7 @@ class Tool(BaseTool): "type": "string", "description": ( "The replacement text for 'replace'. " - "For 'delete' leave this empty (\"\"). " + "For 'delete' leave this as an empty string (\"\"). " "Supplied as-is; do not include identifier prefixes." ), }, diff --git a/cecli/tools/grep.py b/cecli/tools/grep.py index 7c3e26059db..ff3a40bed13 100644 --- a/cecli/tools/grep.py +++ b/cecli/tools/grep.py @@ -1,3 +1,4 @@ +import base64 import os import re import shutil @@ -185,6 +186,195 @@ def _flush_current(): return files +def _build_powershell_exclude_regex(exclude_dirs=None): + """Build a PowerShell -notmatch regex that skips default build/artifact dirs.""" + if exclude_dirs is None: + exclude_dirs = DEFAULT_EXCLUDE_DIRS + fragments = [] + for entry in exclude_dirs: + # Escape the literal text, then turn * globs into path-segment wildcards. + frag = re.escape(entry).replace(r"\*", r"[^\\/]*") + fragments.append(frag) + if not fragments: + return None + return r"(?:\\|/)(?:" + "|".join(fragments) + r")(?:\\|/|$)" + + +def _build_powershell_search_script( + search_dir_path, + pattern, + file_pattern, + use_regex, + case_insensitive, + context_before, + context_after, + count_only, +): + """Build a PowerShell pipeline that mimics rg/ag/grep via Select-String.""" + + def _ps_quote(value): + # Embed a value in a PowerShell single-quoted string ('' escapes a quote). + return "'" + str(value).replace("'", "''") + "'" + + parts = ["Get-ChildItem", "-LiteralPath", _ps_quote(search_dir_path), "-Recurse", "-File"] + + if file_pattern != "*": + parts.extend(["-Filter", _ps_quote(file_pattern)]) + + exclude_regex = _build_powershell_exclude_regex() + if exclude_regex: + parts.extend( + [ + "|", + "Where-Object", + "{", + "$_.FullName", + "-notmatch", + _ps_quote(exclude_regex), + "}", + ] + ) + + parts.extend(["|", "Select-String", "-Pattern", _ps_quote(pattern)]) + + if not use_regex: + parts.append("-SimpleMatch") + if not case_insensitive: + parts.append("-CaseSensitive") + + if count_only: + parts.extend( + [ + "|", + "Group-Object", + "Path", + "|", + "ForEach-Object", + "{", + '"$($_.Name):$($_.Count)"', + "}", + ] + ) + else: + parts.extend(["-Context", f"{context_before},{context_after}"]) + + return " ".join(parts) + + +def _encode_powershell_command(script, powershell_path): + """Encode a PowerShell script for -EncodedCommand and return the full command.""" + encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii") + return ( + f"{powershell_path} -NoProfile -NonInteractive -OutputFormat Text " + f"-EncodedCommand {encoded}" + ) + + +def _parse_select_string_output(output, repo_root): + """Parse Select-String (PowerShell) output into per-file groups. + + With -OutputFormat Text, each match is rendered as: + :: (no context) + and with context as: + > :: (match line) + :: (context line, leading spaces) + + PowerShell renders paths relative to the current directory, so each path is + re-resolved against *repo_root* to an absolute path. This keeps the parsed + groups consistent with the count pass (Group-Object Path -> absolute paths) + and with the downstream truncation/relpath logic. + """ + if not output: + return [] + + file_groups = {} + file_order = [] + + def _flush(current_file, current_lines, match_count): + if current_file is None or not current_lines: + return + if current_file in file_groups: + existing = file_groups[current_file] + existing["match_count"] += match_count + existing["content_parts"].append("\n".join(current_lines)) + else: + file_groups[current_file] = { + "path": current_file, + "match_count": match_count, + "content_parts": ["\n".join(current_lines)], + } + file_order.append(current_file) + + entry_re = re.compile(r"^(.+?)[:-](\d+)[:-](.*)$") + + current_file = None + current_lines = [] + match_count = 0 + + for raw_line in output.splitlines(): + line = raw_line.rstrip("\r") + if not line.strip(): + continue + + # Detect Select-String prefix markers: '> ' for matches, spaces for context. + prefix = "" + is_match = True + rest = line + if line.startswith("> "): + prefix = "> " + rest = line[2:] + elif line.startswith(" "): + prefix = " " + rest = line[2:] + is_match = False + elif line.startswith(" "): + prefix = " " + rest = line.lstrip(" ") + is_match = False + + m = entry_re.match(rest) + if not m: + if current_file is not None: + current_lines.append(line) + continue + + filepath = m.group(1) + abs_path = ( + filepath + if os.path.isabs(filepath) + else os.path.normpath(os.path.join(repo_root, filepath)) + ) + rebuilt = prefix + abs_path + rest[len(filepath) :] + + if current_file is None: + current_file = abs_path + match_count = 1 if is_match else 0 + current_lines = [rebuilt] + elif abs_path == current_file: + current_lines.append(rebuilt) + if is_match: + match_count += 1 + else: + _flush(current_file, current_lines, match_count) + current_file = abs_path + match_count = 1 if is_match else 0 + current_lines = [rebuilt] + + _flush(current_file, current_lines, match_count) + + files = [] + for fpath in file_order: + group = file_groups[fpath] + files.append( + { + "path": group["path"], + "match_count": group["match_count"], + "content": "\n".join(group["content_parts"]), + } + ) + return files + + class Tool(BaseTool): NORM_NAME = "grep" RESULT_TYPE = "list" @@ -258,6 +448,26 @@ def _validate_backend(cls, tool_name, tool_path): """Test if a search backend actually works by running a quick check.""" import subprocess + if tool_name == "powershell": + # PowerShell backend: verify the Select-String cmdlet is available. + test_cmd = [ + tool_path, + "-NoProfile", + "-NonInteractive", + "-Command", + "if (Get-Command Select-String -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }", + ] + try: + result = subprocess.run( + test_cmd, + capture_output=True, + timeout=10, + text=True, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + try: # Test with a simple pattern on a small known file test_cmd = [tool_path, "--version"] @@ -305,10 +515,14 @@ def _validate_backend(cls, tool_name, tool_path): @classmethod def _find_search_tool(self): - """Find the best available command-line search tool (rg, ag, grep).""" - candidates = ["rg", "ag", "grep"] + """Find the best available command-line search tool (rg, ag, grep, or PowerShell Select-String).""" + candidates = ["rg", "ag", "grep", "powershell"] for name in candidates: - path = shutil.which(name) + if name == "powershell": + # PowerShell (Select-String) is the Windows-native fallback. + path = shutil.which("powershell") or shutil.which("pwsh") + else: + path = shutil.which(name) if not path: continue if self._validate_backend(name, path): @@ -325,6 +539,7 @@ def execute( """ Search for lines matching patterns in files within the project repository. Uses rg (ripgrep), ag (the silver searcher), or grep, whichever is available. + On Windows these may be absent, so PowerShell's Select-String is used as a fallback. Returns a JSON string with structured results including per-file groupings, match counts, and summary metadata. """ @@ -344,9 +559,9 @@ def execute( tool_name, tool_path = cls._find_search_tool() if not tool_path: - coder.io.tool_error("No search tool (rg, ag, grep) found in PATH.") + coder.io.tool_error("No search tool (rg, ag, grep, powershell) found in PATH.") response = ToolResponse(cls.NORM_NAME, result_type=cls.RESULT_TYPE) - response.append_error("No search tool (rg, ag, grep) found.") + response.append_error("No search tool (rg, ag, grep, powershell) found.") return response all_operation_results = [] @@ -385,48 +600,61 @@ def execute( try: search_dir_path = Path(repo.root) / directory - # Build base content command - base_cmd = [tool_path, "-n"] - if tool_name == "rg": - base_cmd.append("--with-filename") - - # Pattern type - pattern_flag = [] - if use_regex: - if tool_name == "grep": - pattern_flag = ["-E"] + if tool_name == "powershell": + # PowerShell (Select-String) backend: build a pipeline script and + # invoke it via -EncodedCommand to avoid shell quoting issues. + content_string = _encode_powershell_command( + _build_powershell_search_script( + search_dir_path=search_dir_path, + pattern=pattern, + file_pattern=file_pattern, + use_regex=use_regex, + case_insensitive=case_insensitive, + context_before=context_before, + context_after=context_after, + count_only=False, + ), + tool_path, + ) else: + # Build base content command + base_cmd = [tool_path, "-n"] if tool_name == "rg": - pattern_flag = ["-F"] - elif tool_name == "ag": - pattern_flag = ["-Q"] - elif tool_name == "grep": - pattern_flag = ["-F"] - - # Case sensitivity - case_flag = ["-i"] if case_insensitive else [] + base_cmd.append("--with-filename") - # File filtering - file_filter = [] - if file_pattern != "*": - if tool_name == "rg": - file_filter = ["-g", file_pattern] - elif tool_name == "ag": - file_filter = ["-G", file_pattern] + # Pattern type + pattern_flag = [] + if use_regex: + if tool_name == "grep": + pattern_flag = ["-E"] + else: + if tool_name == "rg": + pattern_flag = ["-F"] + elif tool_name == "ag": + pattern_flag = ["-Q"] + elif tool_name == "grep": + pattern_flag = ["-F"] + + # Case sensitivity + case_flag = ["-i"] if case_insensitive else [] + + # File filtering + file_filter = [] + if file_pattern != "*": + if tool_name == "rg": + file_filter = ["-g", file_pattern] + elif tool_name == "ag": + file_filter = ["-G", file_pattern] + elif tool_name == "grep": + file_filter = ["-r", f"--include={file_pattern}"] elif tool_name == "grep": - file_filter = ["-r", f"--include={file_pattern}"] - elif tool_name == "grep": - file_filter = ["-r"] + file_filter = ["-r"] - # Exclusions - exclude_args = [] - _build_exclude_args(tool_name, exclude_args) + # Exclusions + exclude_args = [] + _build_exclude_args(tool_name, exclude_args) - # --- PASS 1: Get match counts (fast, no context) --- - counts = {} - if count_enabled: - # Build count command (separate flags to avoid -r confusion) - # NOTE: rg -r = --replace (takes arg), not recursive. rg is recursive by default. + # --- PASS1: Build count command (fast, no context) --- count_cmd_parts = [tool_path] if tool_name == "rg": count_cmd_parts.append("-c") @@ -443,6 +671,23 @@ def execute( + ["--", pattern, str(search_dir_path)] ) count_string = oslex.join(count_cmd) + + # --- PASS2: Build content with context --- + content_cmd = ( + base_cmd + + (["-B", str(context_before)] if context_before >= 0 else []) + + (["-A", str(context_after)] if context_after >= 0 else []) + + case_flag + + pattern_flag + + exclude_args + + file_filter + + ["--", pattern, str(search_dir_path)] + ) + content_string = oslex.join(content_cmd) + + # --- PASS1: Get match counts (fast, no context) --- + counts = {} + if count_enabled and tool_name != "powershell": coder.io.tool_output( f"⛭ Counting matches with {tool_name}: '{pattern}' in {directory}", type="tool-result", @@ -456,18 +701,7 @@ def execute( if count_status == 0: counts = _parse_count_output(count_output) - # --- PASS 2: Get content with context --- - content_cmd = ( - base_cmd - + (["-B", str(context_before)] if context_before >= 0 else []) - + (["-A", str(context_after)] if context_after >= 0 else []) - + case_flag - + pattern_flag - + exclude_args - + file_filter - + ["--", pattern, str(search_dir_path)] - ) - content_string = oslex.join(content_cmd) + # --- PASS2: Get content with context --- coder.io.tool_output( f"⛭ Executing {tool_name}: '{pattern}' in {directory}", type="tool-result", @@ -482,7 +716,10 @@ def execute( output_content = content_output or "" if content_status == 0 and output_content: - parsed_files = _parse_content_into_files(output_content) + if tool_name == "powershell": + parsed_files = _parse_select_string_output(output_content, repo.root) + else: + parsed_files = _parse_content_into_files(output_content) # Merge in counts from pass 1 if available if counts: @@ -510,7 +747,7 @@ def execute( file_lines = pf["content"].splitlines() # Find actual match lines (lines with `:LINE:` pattern) filepath_escaped = re.escape(pf["path"]) - match_line_re = re.compile(r"^" + filepath_escaped + r":(\d+):") + match_line_re = re.compile(r"^(?:> )?" + filepath_escaped + r":(\d+):") match_lines_found = [ln for ln in file_lines if match_line_re.match(ln)] # Normalize path to be relative to repo root diff --git a/cecli/tools/utils/registry.py b/cecli/tools/utils/registry.py index 7b5e516c749..bca50fcac87 100644 --- a/cecli/tools/utils/registry.py +++ b/cecli/tools/utils/registry.py @@ -63,13 +63,17 @@ def list_tools(cls) -> List[str]: return list(cls._tools.keys()) @classmethod - def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: + def build_registry( + cls, agent_config: Optional[Dict] = None, root: Optional[str] = None + ) -> Dict[str, Type]: """ Build a filtered registry of tools based on agent configuration. Args: agent_config: Agent configuration dictionary with optional tools_includelist/tools_excludelist keys + root: Optional base directory used to resolve relative + ``tools_paths``. Defaults to the working directory. Returns: A dictionary mapping normalized tool names to tool classes. @@ -78,12 +82,34 @@ def build_registry(cls, agent_config: Optional[Dict] = None) -> Dict[str, Type]: if agent_config is None: agent_config = {} + # Resolve tools_paths relative to root when provided (used by + # workspace sub-agents so custom tools resolve against the primary + # workspace root, not the sub-agent's overridden local root). + base = Path(root).expanduser().resolve() if root else None + + def _resolve(tool_path: str) -> Path: + raw = Path(tool_path).expanduser() + if raw.is_absolute(): + return raw.resolve() + if base is not None: + return (base / raw).resolve() + return raw.resolve() + # Load tools from tool_paths if specified tools_paths = agent_config.get("tools_paths", agent_config.get("tool_paths", [])) loaded_custom_tools = [] + # Always scan the default custom-tools directories: the global default + # in the user's home and the local default under the given root. The + # global default is scanned first, the local default next so it takes + # precedence, and any explicitly configured tools_paths last. + default_tools_dirs = [Path.home() / ".cecli" / "tools"] + if base is not None: + default_tools_dirs.append(base / ".cecli" / "tools") + tools_paths = [str(p) for p in default_tools_dirs] + list(tools_paths) + for tool_path in tools_paths: - path = Path(tool_path) + path = _resolve(tool_path) if path.is_dir(): # Find all Python files in the directory for py_file in path.glob("*.py"): diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 1b7cb8f9541..0910b788d47 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -502,18 +502,17 @@ def _update_key_hints_for_commands(self, text: str, is_completion: bool = False) self.update_key_hints_left(KeyHints.DEFAULT_LEFT_TEXT) def _load_git_info(self): - """Load git branch and dirty count (deferred to avoid blocking startup).""" + """Load git branch (deferred to avoid blocking startup).""" footer = self.query_one(MainFooter) if self.worker.coder.repo: try: branch = self.worker.coder.repo.repo.active_branch.name or "main" - dirty = self.worker.coder.repo.get_dirty_files() - footer.update_git(branch, len(dirty) if dirty else 0) + footer.update_git(branch) except Exception: if self.worker.coder.repo: - footer.update_git("main", 0) + footer.update_git("main") else: - footer.update_git("No Repo", 0) + footer.update_git("No Repo") def check_output_queue(self): """Process messages from coder worker.""" @@ -1247,6 +1246,8 @@ def action_switch_to_primary(self) -> None: # Update input autocomplete data for the primary agent self.enable_input({}, coder=self.worker.coder) + self._refresh_footer() + def action_switch_prev_agent(self) -> None: """Switch to the previous agent (primary or sub-agent), wrapping around.""" if not self._sub_agent_containers: @@ -1320,6 +1321,8 @@ def _switch_to_container(self, uuid: str, suppress_input_enable: bool = False) - coder = agent_service.foreground_coder self.enable_input({}, coder=coder) + self._refresh_footer() + def create_sub_agent_container(self, uuid: str, name: str) -> None: """Create an OutputContainer for a sub-agent.""" from cecli.helpers.agents.service import AgentService @@ -1378,10 +1381,45 @@ def remove_sub_agent_container(self, uuid: str) -> None: agent_service.foreground_uuid = None primary = self.query_one("#output", OutputContainer) primary.display = True + self._refresh_footer() # Sync border title with mode and sub-agent info self._sync_sub_agent_display() + def _project_name_for_coder(self, coder) -> str: + """Compute a display project name for a coder's root (home-shortened).""" + home = str(Path.home()) + repo = getattr(coder, "repo", None) + root = str(getattr(coder, "root", "") or "") + if not root and repo: + root = str(repo.root) + if not root: + root = str(Path.cwd()) + if root.startswith(home): + project_name = root.replace(home, "~", 1) + else: + project_name = root + if len(project_name) >= 64: + project_name = project_name.split("/")[-1] + return project_name + + def _refresh_footer(self): + """Refresh the footer with the active coder's project/root and git branch.""" + try: + footer = self.query_one(MainFooter) + coder = self._get_visible_coder() + project_name = self._project_name_for_coder(coder) + branch = "" + repo = getattr(coder, "repo", None) + if repo: + try: + branch = repo.repo.active_branch.name or "main" + except Exception: + branch = branch or "main" + footer.update_info(project_name, branch) + except Exception: + pass + def _sync_sub_agent_display(self) -> None: """Update the InputContainer border title with mode and sub-agent pills. @@ -1603,7 +1641,9 @@ def _get_path_completions(self, prefix: str) -> tuple[list[str], set[str]]: # Try FileSystemService first for efficient lookups try: - fs = FileSystemService.get_instance() + fs = getattr(coder, "fs", None) or FileSystemService.for_root( + str(root), repo=getattr(coder, "repo", None) + ) if prefix: if fs.trie: is_fuzzy = False diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index 5f77cdae230..6ea1983ddb4 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -16,7 +16,6 @@ class MainFooter(Static): # Right side info project_name = reactive("") git_branch = reactive("") - git_dirty = reactive(0) cost = reactive(0.0) # Spinner state @@ -138,8 +137,6 @@ def render(self) -> Text: if self.git_branch: right.append(self.git_branch) - # if self.git_dirty: - # right.append(f" +{self.git_dirty}") # right.append(" ") # Always show cost @@ -168,10 +165,15 @@ def update_cost(self, cost: float): self.cost = cost self.refresh() - def update_git(self, branch: str, dirty_count: int = 0): + def update_git(self, branch: str): """Update git status display.""" self.git_branch = branch - self.git_dirty = dirty_count + self.refresh() + + def update_info(self, project_name: str, branch: str): + """Update the project/root and git status display together.""" + self.project_name = project_name + self.git_branch = branch self.refresh() def update_mode(self, mode: str): diff --git a/cecli/utils.py b/cecli/utils.py index f4341e03e7f..438c4ec33d0 100644 --- a/cecli/utils.py +++ b/cecli/utils.py @@ -127,7 +127,7 @@ def __init__(self): def __enter__(self): res = super().__enter__() os.chdir(Path(self.temp_dir.name).resolve()) - return res + return str(Path(res).resolve()) def __exit__(self, exc_type, exc_val, exc_tb): if self.cwd: @@ -299,16 +299,61 @@ def get_pip_install(args): return cmd +def get_self_upgrade_command(): + """Return the command used to upgrade cecli for the current install method.""" + # pipx sets these environment variables inside each tool's venv + if os.environ.get("PIPX_VENV_DIR") or os.environ.get("PIPX_PACKAGE_DIR"): + return ["pipx", "upgrade", "cecli-dev"] + + if _is_uv_tool_env(): + return ["uv", "tool", "upgrade", "cecli-dev"] + + return get_pip_install(["cecli-dev"]) + + +def _is_pip_install_command(cmd): + """Return True if cmd is a `python -m pip install ...` invocation.""" + try: + i = cmd.index("-m") + except ValueError: + return False + return i + 2 < len(cmd) and cmd[i + 1] == "pip" and cmd[i + 2] == "install" + + +def _is_uv_tool_env(): + """Return True if running inside a `uv tool install` environment.""" + cfg = Path(sys.prefix) / "pyvenv.cfg" + try: + if not (cfg.exists() and "uv = " in cfg.read_text(errors="replace")): + return False + except OSError: + return False + + prefix = Path(sys.prefix) + tool_dir = os.environ.get("UV_TOOL_DIR") + if tool_dir: + try: + if prefix.is_relative_to(tool_dir): + return True + except (ValueError, OSError): + pass + + # Default uv tool installs live under ".../uv/tools/". + # A plain `uv venv` project venv does not have the uv/tools path parts. + return "uv" in prefix.parts and "tools" in prefix.parts + + def run_install(cmd): print() print("Installing:", printable_shell_command(cmd)) - # First ensure pip is available - ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"] - try: - subprocess.run(ensurepip_cmd, capture_output=True, check=False) - except Exception: - pass # Continue even if ensurepip fails + if _is_pip_install_command(cmd): + # First ensure pip is available + ensurepip_cmd = [sys.executable, "-m", "ensurepip", "--upgrade"] + try: + subprocess.run(ensurepip_cmd, capture_output=True, check=False) + except Exception: + pass # Continue even if ensurepip fails try: from cecli.waiting import Spinner @@ -386,7 +431,9 @@ def touch_file(fname): return False -async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_update=False): +async def check_pip_install_extra( + io, module, prompt, pip_install_cmd=None, self_update=False, cmd=None +): if module: for _attempt in range(2): try: @@ -403,7 +450,8 @@ async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_upda except (ImportError, ModuleNotFoundError, RuntimeError): break - cmd = get_pip_install(pip_install_cmd) + if cmd is None: + cmd = get_pip_install(pip_install_cmd or []) if prompt: io.tool_warning(prompt) @@ -414,9 +462,13 @@ async def check_pip_install_extra(io, module, prompt, pip_install_cmd, self_upda print(printable_shell_command(cmd)) # plain print so it doesn't line-wrap return - if not await io.confirm_ask( - "Run pip install?", default="y", subject=printable_shell_command(cmd) - ): + run_prompt = "Run pip install?" + if cmd and cmd[0] == "uv": + run_prompt = "Run uv tool upgrade?" + elif cmd and cmd[0] == "pipx": + run_prompt = "Run pipx upgrade?" + + if not await io.confirm_ask(run_prompt, default="y", subject=printable_shell_command(cmd)): return success, output = run_install(cmd) diff --git a/cecli/versioncheck.py b/cecli/versioncheck.py index 95ff02b32b7..b0681501a9e 100644 --- a/cecli/versioncheck.py +++ b/cecli/versioncheck.py @@ -40,7 +40,7 @@ async def install_upgrade(io, latest_version=None): io.tool_warning(text) return True success = await utils.check_pip_install_extra( - io, None, new_ver_text, ["cecli-dev"], self_update=True + io, None, new_ver_text, cmd=utils.get_self_upgrade_command(), self_update=True ) if success: io.tool_output("Re-run cecli to use new version.") @@ -48,8 +48,8 @@ async def install_upgrade(io, latest_version=None): return -async def check_version(io, just_check=False, verbose=False): - if not just_check and VERSION_CHECK_FNAME.exists(): +async def check_version(io, just_check=False, verbose=False, upgrade=False): + if not just_check and not upgrade and VERSION_CHECK_FNAME.exists(): day = 60 * 60 * 24 since = time.time() - os.path.getmtime(VERSION_CHECK_FNAME) if 0 < since < day: @@ -66,10 +66,8 @@ async def check_version(io, just_check=False, verbose=False): current_version = cecli.__version__ if just_check or verbose: io.tool_output(f"Current version: {current_version}") - io.tool_output(f"Latest version: {latest_version}") - is_update_available = ( - packaging.version.parse(latest_version).release - > packaging.version.parse(current_version).release + is_update_available = packaging.version.parse(latest_version) > packaging.version.parse( + current_version ) except Exception as err: io.tool_error(f"Error checking pypi for new version: {err}") diff --git a/cecli/website/docs/config.md b/cecli/website/docs/config.md index 113a324d946..19a106e8a79 100644 --- a/cecli/website/docs/config.md +++ b/cecli/website/docs/config.md @@ -36,7 +36,7 @@ CECLI_TUI=true ## Default File Locations -Cecli also checks several default locations inside `~/.cecli/` for configuration, environment variables, and agent resources. These are always included with lower precedence than project-level equivalents, so a setting in a project `.cecli.conf.yml` or `.env` file will override the `~/.cecli/` default. +Cecli also checks several default locations inside `~/.cecli/` for configuration, environment variables, and agent resources. These are always included with lower precedence than project-level equivalents, so a setting in a project `.cecli.conf.yml` or `.env` file will override the `~/.cecli/` default. The agent resource registries below each also look for a **local** default under an agent's working root, which takes precedence over the global default. ### `~/.cecli/conf.yml` @@ -48,11 +48,21 @@ An environment file loaded before any other `.env` file, so project-level `.env` ### `~/.cecli/skills/` -A directory containing skill packages (each a sub-directory with a `SKILL.md` file). Skills here are discoverable alongside those in any user-configured skills paths. +`Local: .cecli/skills/` + +A directory containing skill packages (each a sub-directory with a `SKILL.md` file). ### `~/.cecli/subagents/` -A directory containing sub-agent definition files (`.md` files with YAML front matter). Sub-agents here are registered alongside those in any user-configured sub-agent paths. +`Local: .cecli/subagents/` + +A directory containing sub-agent definition files (`.md` files with YAML front matter). + +### `~/.cecli/tools/` + +`Local: .cecli/tools/` + +A directory containing custom tool packages (`.py` files exposing a `Tool` class). > **Tip:** > See the [API key configuration docs](config/api-keys.html) for information on how to configure and store your API keys. diff --git a/cecli/website/docs/config/subagents.md b/cecli/website/docs/config/subagents.md index de25d363907..7f65bc516ad 100644 --- a/cecli/website/docs/config/subagents.md +++ b/cecli/website/docs/config/subagents.md @@ -73,6 +73,7 @@ agent-config: |---------|-------------| | `/spawn-agent ` | Spawn a sub-agent without a prompt (non-blocking — waits for user input) | | `/spawn-agent ` | Spawn a sub-agent with a prompt (non-blocking — starts processing immediately) | +| `/open ` | Register and open a `ws:{name}` workspace sub-agent rooted at `` (ad-hoc, no config file required) | | `/reap-agent` | Force destroy the currently active sub-agent | > **Tip**: `/spawn-agent` supports tab completion of sub-agent names. diff --git a/cecli/website/docs/config/workspaces.md b/cecli/website/docs/config/workspaces.md index 822aead0b5a..747c93c96f7 100644 --- a/cecli/website/docs/config/workspaces.md +++ b/cecli/website/docs/config/workspaces.md @@ -1,130 +1,145 @@ --- parent: Configuration nav_order: 41 -description: Workspaces allow you to work across multiple related repositories simultaneously +description: Workspaces turn multiple git repositories into agent-ready sub-agents you can spin up --- # Workspaces -Workspaces allow you to manage multiple git repositories within a single monorepo-like folder structure, enabling development across multiple related projects. `cecli` supports two workspace modes: +Workspaces let you manage several git repositories as one unit and, crucially, turn each repository into an agent you can delegate to. A workspace is defined by a `.cecli.workspaces.yml` file that lists its projects. Each project is either: -**clone** workspaces (remote `repo:` URLs cloned into `~/.cecli/workspaces/`) +- **local** — an existing on-disk git root referenced by an absolute `path:` (used in-place, no cloning). +- **clone** — a remote `repo:` URL that is cloned into `~/.cecli/workspaces/{workspace}/{project}/main`. -**local** workspaces (existing on-disk git roots referenced by absolute `path:`) +## Workspace sub-agents -## Configuration +When a workspace is active, each project automatically becomes an **implicit `ws:{project}` sub-agent**. These agents are modelled on the built-in `worker` sub-agent but with two key differences: + +- Their `root` is **overridden** to point at the project's git root (the + in-place `path:` directory for local projects, or the cloned checkout for `repo:` projects), so the agent operates inside that repository. +- Their agent config sets `allow_nested_delegation: true`, so a `ws:*` agent can itself serve as a base for further delegations. + +Because `ws:{name}` agents are registered with the sub-agent registry, they are available through the normal mechanisms: + +- The `Delegate` tool (from a primary agent) +- `/spawn-agent ws:{name}` (interactively) +- `/open ` (ad-hoc, no config file required) -You can configure workspaces in multiple locations. `cecli` searches for configurations in the following order: +You can find the agent name reported by `/workspace` (e.g. `ws:app`). -1. **CLI Argument**: Via a JSON/YAML configuration or file path passed to the `--workspaces` argument. -2. **Local Workspaces File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` in the current directory. -3. **Global Workspaces File**: `~/.cecli/workspaces.yml` or `~/.cecli/workspaces.yaml`. +`/open app /path/to/app` opens a `ws:app` sub-agent rooted at the given path without needing a `.cecli.workspaces.yml` file. The path must be an existing git root; the agent is registered immediately and becomes the foreground agent. + +Each project may carry a `metadata` block that configures its `ws:{name}` agent the same way a sub-agent `.md` front-matter does: `model`, `hooks` and `auto_reap` map to the config fields, and any other key (e.g. `agent-config`) is merged into the agent's metadata. `root`, `name` and `description` are always derived from the project definition and cannot be overridden by `metadata`. + +## Configuration -4. **Repo-Local Config File**: `.cecli.workspaces.yml` or `.cecli.workspaces.yaml` placed at a common ancestor of your project directories. `cecli` discovers this file by walking up from any project path, enabling a **local** workspace layout without cloning into `~/.cecli/workspaces/`. +`cecli` searches for workspace config in the following order: + +1. **CLI argument** — a JSON/YAML string or file path passed to `--workspaces`. +2. **Local workspace file** — `.cecli.workspaces.yml` / `.cecli.workspaces.yaml` + in the current directory, or at a common ancestor of the project + directories (discovered by walking up from any project path). +3. **Global workspace file** — `~/.cecli/workspaces.yml` / `.cecli/workspaces.yaml`. ### Example Configuration ```yaml workspaces: - name: "my-workspace" + name: my-workspace projects: - - name: "frontend" - repo: "https://github.com/user/frontend.git" - branch: "main" - worktrees: - - name: "feature-auth" - branch: "feature/auth" - - name: "backend" - repo: "https://github.com/user/backend.git" - branch: "develop" - use_current_branch: true # Default: true. Set to false to force branch switching on init. - ignore: "~/.cecli/backend.ignore" # Optional: Path to a custom ignore file for this project + - name: app + path: /abs/path/to/app + primary: true # At most one project can be primary + metadata: # Optional sub-agent front-matter + model: + agent-config: + skills_paths: ["~/my-skills", "./project-skills"] + skills_includelist: ["python-refactoring", "react-components"] + - name: lib + path: /abs/path/to/lib + - name: docs + repo: https://github.com/user/docs.git + branch: main + use_current_branch: false # Force checkout of `branch` on init + ignore: ~/.cecli/docs.ignore # Optional custom ignore file ``` -### Local Workspace Configuration - -For **local** workspaces, place a `.cecli.workspaces.yml` file at a common ancestor of your project directories. Each project references an existing git root via `path:` instead of a remote `repo:` URL. +### Project Fields -```yaml -# .cecli.workspaces.yml -name: my-workspace -projects: - - name: app - path: /abs/path/to/app - primary: true # At most one project can be primary - - name: lib - path: /abs/path/to/lib - readonly: true # Prevents commits to this project -``` +| Field | Required | Description | +|---------------------|----------|-------------| +| `name` | Yes | Unique project name; also names the `ws:{name}` sub-agent | +| `path` | One of | Absolute path to an existing local git root | +| `repo` | One of | Remote clone URL (cloned under `~/.cecli/workspaces/`) | +| `primary` | No | At most one project may set `primary: true` | +| `branch` | No | Branch to check out when cloning (`repo:` projects) | +| `use_current_branch`| No | Default `true`; set `false` to force branch switching on init | +| `ignore` | No | Path to a custom ignore file for this project | +| `metadata` | No | Optional sub-agent front-matter for the `ws:{name}` agent | **Validation rules:** -- Each project must have a `name` and **exactly one** of `path` (local git root) or `repo` (clone URL). -- At most one project can be marked `primary: true`. -- Projects with `readonly: true` are excluded from commits. +- Each project must have a `name` and **exactly one** of `path` or `repo`. +- At most one project may be marked `primary: true`. +- Project names must be unique (they become `ws:{name}` agent names). ### Path Layout -The workspace layout determines how file paths are structured within the workspace: - | Layout | Prefix | Example | |--------|--------|--------| | **clone** (repo-based) | `{project}/main/{file}` | `app/main/src/main.py` | | **local** (path-based) | `{project}/{file}` | `app/src/main.py` | - ### Multiple Workspaces -You can define a list of workspaces. Use the `active: true` flag to specify which one should be used by default when running `cecli` without the `--workspace-name` argument. **Note: At most one workspace can be marked as active.** +You can define a list of workspaces and mark one `active: true` (at most one): + ```yaml workspaces: - - name: "project-a" + - name: project-a active: true projects: - - name: "app" - repo: "https://github.com/user/app.git" - - name: "project-b" + - name: app + path: /abs/path/to/app + - name: project-b projects: - - name: "api" - repo: "https://github.com/user/api.git" + - name: api + repo: https://github.com/user/api.git ``` - ## Usage -To use a workspace: - ```bash cecli --workspace-name my-workspace # OR if using a specific config file cecli --workspaces path/to/workspaces.yml --workspace-name my-workspace ``` -If the workspace does not exist, `cecli` will create the directory structure at `~/.cecli/workspaces/my-workspace/` and clone the configured repositories. For **local** workspaces, the configured `path:` directories are used in-place — no cloning occurs. - -### Clone Workspace Structure +Activating a workspace registers a `ws:{name}` sub-agent for each resolvable project. The primary agent's root is **unchanged** — multi-project work happens by delegating to the `ws:{name}` sub-agents, each rooted at its own project. -``` -~/.cecli/workspaces/ -└── workspace-name/ - ├── .cecli-workspace.json - └── project-name/ - ├── main/ # Main repository clone - └── worktrees/ # Additional worktrees -``` +- For **local** workspaces, the configured `path:` directories are used + in-place — no cloning occurs. +- For **clone** workspaces, `cecli` creates `~/.cecli/workspaces/{workspace}/` and clones each `repo:` project into `{workspace}/{project}/main`. -### Local Workspace Structure - -Local workspaces do **not** create a `~/.cecli/workspaces/` directory. Instead, the config file directory itself serves as the workspace root, with metadata stored at: +Metadata is stored at the workspace root: ``` .cecli/ └── .workspace-meta.json ``` -The project directories exist at their configured `path:` locations on disk. +Clone workspaces materialise under `~/.cecli/workspaces/`: -## Arguments +``` +~/.cecli/workspaces/ +└── my-workspace/ + ├── .cecli/ + │ └── .workspace-meta.json + └── app/ + └── main/ # git clone of `repo:` +``` -`--workspaces `: Provide a JSON/YAML configuration or file path for workspace initialization. +## Arguments -`--workspace-name `: Specify the workspace name to activate from the configuration. +- `--workspaces `: Provide a JSON/YAML configuration or file path for + workspace initialization. +- `--workspace-name `: Specify the workspace name to activate. diff --git a/requirements.txt b/requirements.txt index 434705d7ce4..56d75692d3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,17 @@ # This file was autogenerated by uv via the following command: # uv pip compile --no-strip-extras --constraint=requirements/common-constraints.txt --output-file=tmp.requirements.txt requirements/requirements.in +aiohappyeyeballs==2.6.1 + # via + # -c requirements/common-constraints.txt + # aiohttp +aiohttp==3.14.3 + # via + # -c requirements/common-constraints.txt + # kubernetes +aiosignal==1.4.0 + # via + # -c requirements/common-constraints.txt + # aiohttp annotated-types==0.7.0 # via # -c requirements/common-constraints.txt @@ -15,8 +27,13 @@ anyio==4.11.0 attrs==25.4.0 # via # -c requirements/common-constraints.txt + # aiohttp # jsonschema # referencing +bcrypt==5.0.0 + # via + # -c requirements/common-constraints.txt + # chromadb beautifulsoup4==4.14.2 # via # -c requirements/common-constraints.txt @@ -25,11 +42,16 @@ blinker==1.9.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +build==1.3.0 + # via + # -c requirements/common-constraints.txt + # chromadb certifi==2025.11.12 # via # -c requirements/common-constraints.txt # httpcore # httpx + # kubernetes # requests cffi==2.0.0 # via @@ -42,9 +64,14 @@ charset-normalizer==3.4.9 # -c requirements/common-constraints.txt # -r requirements/requirements.in # requests +chromadb==1.5.9 + # via + # -c requirements/common-constraints.txt + # -r requirements/requirements.in click==8.3.1 # via # -c requirements/common-constraints.txt + # typer # uvicorn configargparse==1.7.1 # via @@ -63,6 +90,27 @@ diskcache==5.6.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +durationpy==0.10 + # via + # -c requirements/common-constraints.txt + # kubernetes +filelock==3.20.0 + # via + # -c requirements/common-constraints.txt + # huggingface-hub +flatbuffers==25.12.19 + # via + # -c requirements/common-constraints.txt + # onnxruntime +frozenlist==1.8.0 + # via + # -c requirements/common-constraints.txt + # aiohttp + # aiosignal +fsspec==2025.10.0 + # via + # -c requirements/common-constraints.txt + # huggingface-hub gitdb==4.0.12 # via # -c requirements/common-constraints.txt @@ -71,29 +119,52 @@ gitpython==3.1.45 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +googleapis-common-protos==1.75.1 + # via + # -c requirements/common-constraints.txt + # opentelemetry-exporter-otlp-proto-grpc +grpcio==1.83.0 + # via + # -c requirements/common-constraints.txt + # chromadb + # opentelemetry-exporter-otlp-proto-grpc h11==0.16.0 # via # -c requirements/common-constraints.txt # httpcore # uvicorn +hf-xet==1.2.0 + # via + # -c requirements/common-constraints.txt + # huggingface-hub httpcore==1.0.9 # via # -c requirements/common-constraints.txt # httpx +httptools==0.8.0 + # via + # -c requirements/common-constraints.txt + # uvicorn httpx==0.28.1 # via # -c requirements/common-constraints.txt + # chromadb # mcp httpx-sse==0.4.3 # via # -c requirements/common-constraints.txt # mcp +huggingface-hub==0.36.0 + # via + # -c requirements/common-constraints.txt + # tokenizers idna==3.11 # via # -c requirements/common-constraints.txt # anyio # httpx # requests + # yarl importlib-metadata==8.7.0 # via # -c requirements/common-constraints.txt @@ -102,6 +173,7 @@ importlib-resources==6.5.2 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb json-repair==0.60.1 # via # -c requirements/common-constraints.txt @@ -110,11 +182,16 @@ jsonschema==4.25.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb # mcp jsonschema-specifications==2025.9.1 # via # -c requirements/common-constraints.txt # jsonschema +kubernetes==36.0.3 + # via + # -c requirements/common-constraints.txt + # chromadb linkify-it-py==2.0.3 # via # -c requirements/common-constraints.txt @@ -141,10 +218,19 @@ mdurl==0.1.2 # via # -c requirements/common-constraints.txt # markdown-it-py +mmh3==5.2.1 + # via + # -c requirements/common-constraints.txt + # chromadb mslex==1.3.0 # via # -c requirements/common-constraints.txt # oslex +multidict==6.7.0 + # via + # -c requirements/common-constraints.txt + # aiohttp + # yarl ngram==4.0.3 # via # -c requirements/common-constraints.txt @@ -152,20 +238,67 @@ ngram==4.0.3 numpy==2.3.5 # via # -c requirements/common-constraints.txt + # chromadb + # onnxruntime # rustworkx # soundfile +oauthlib==3.3.1 + # via + # -c requirements/common-constraints.txt + # requests-oauthlib +onnxruntime==1.29.0 + # via + # -c requirements/common-constraints.txt + # chromadb +opentelemetry-api==1.44.0 + # via + # -c requirements/common-constraints.txt + # chromadb + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp-proto-common==1.44.0 + # via + # -c requirements/common-constraints.txt + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-grpc==1.44.0 + # via + # -c requirements/common-constraints.txt + # chromadb +opentelemetry-proto==1.44.0 + # via + # -c requirements/common-constraints.txt + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-sdk==1.44.0 + # via + # -c requirements/common-constraints.txt + # chromadb + # opentelemetry-exporter-otlp-proto-grpc +opentelemetry-semantic-conventions==0.65b0 + # via + # -c requirements/common-constraints.txt + # opentelemetry-sdk orjson==3.11.9 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb oslex==0.1.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +overrides==7.7.0 + # via + # -c requirements/common-constraints.txt + # chromadb packaging==25.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # build + # huggingface-hub + # onnxruntime pathspec==0.12.1 # via # -c requirements/common-constraints.txt @@ -186,6 +319,17 @@ prompt-toolkit==3.0.52 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +propcache==0.4.1 + # via + # -c requirements/common-constraints.txt + # aiohttp + # yarl +protobuf==7.36.0 + # via + # -c requirements/common-constraints.txt + # googleapis-common-protos + # onnxruntime + # opentelemetry-proto psutil==7.1.3 # via # -c requirements/common-constraints.txt @@ -198,6 +342,10 @@ py-cymbal==0.2.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +pybase64==1.5.0 + # via + # -c requirements/common-constraints.txt + # chromadb pycparser==2.23 # via # -c requirements/common-constraints.txt @@ -205,6 +353,7 @@ pycparser==2.23 pydantic==2.12.4 # via # -c requirements/common-constraints.txt + # chromadb # mcp # pydantic-settings pydantic-core==2.41.5 @@ -214,6 +363,7 @@ pydantic-core==2.41.5 pydantic-settings==2.12.0 # via # -c requirements/common-constraints.txt + # chromadb # mcp pydub==0.25.1 # via @@ -236,11 +386,24 @@ pyperclip==1.11.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +pypika==0.51.1 + # via + # -c requirements/common-constraints.txt + # chromadb +pyproject-hooks==1.2.0 + # via + # -c requirements/common-constraints.txt + # build +python-dateutil==2.9.0.post0 + # via + # -c requirements/common-constraints.txt + # kubernetes python-dotenv==1.2.2 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in # pydantic-settings + # uvicorn python-multipart==0.0.20 # via # -c requirements/common-constraints.txt @@ -249,6 +412,10 @@ pyyaml==6.0.3 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb + # huggingface-hub + # kubernetes + # uvicorn rapidfuzz==3.14.5 # via # -c requirements/common-constraints.txt @@ -262,11 +429,20 @@ requests==2.32.5 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # huggingface-hub + # kubernetes + # requests-oauthlib +requests-oauthlib==2.0.0 + # via + # -c requirements/common-constraints.txt + # kubernetes rich==14.2.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb # textual + # typer rpds-py==0.29.0 # via # -c requirements/common-constraints.txt @@ -276,10 +452,19 @@ rustworkx==0.17.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +shellingham==1.5.4 + # via + # -c requirements/common-constraints.txt + # typer shtab==1.8.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +six==1.17.0 + # via + # -c requirements/common-constraints.txt + # kubernetes + # python-dateutil smmap==5.0.2 # via # -c requirements/common-constraints.txt @@ -312,10 +497,18 @@ starlette==0.50.0 # via # -c requirements/common-constraints.txt # mcp +tenacity==9.1.2 + # via + # -c requirements/common-constraints.txt + # chromadb textual==8.2.8 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +tokenizers==0.22.1 + # via + # -c requirements/common-constraints.txt + # chromadb tomlkit==0.14.0 # via # -c requirements/common-constraints.txt @@ -324,6 +517,8 @@ tqdm==4.67.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # chromadb + # huggingface-hub # via # -c requirements/common-constraints.txt # -r requirements/requirements.in @@ -353,17 +548,31 @@ truststore==0.10.4 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +typer==0.20.0 + # via + # -c requirements/common-constraints.txt + # chromadb typing-extensions==4.15.0 # via # -c requirements/common-constraints.txt + # aiohttp + # aiosignal # anyio # beautifulsoup4 + # chromadb + # grpcio + # huggingface-hub # mcp + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-sdk + # opentelemetry-semantic-conventions # pydantic # pydantic-core # referencing # starlette # textual + # typer # typing-inspection typing-inspection==0.4.2 # via @@ -378,27 +587,43 @@ uc-micro-py==1.0.3 urllib3==2.5.0 # via # -c requirements/common-constraints.txt + # kubernetes # requests -uvicorn==0.38.0 +uvicorn[standard]==0.38.0 # via # -c requirements/common-constraints.txt + # chromadb # mcp +uvloop==0.22.1 + # via + # -c requirements/common-constraints.txt + # uvicorn watchfiles==1.1.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # uvicorn wcwidth==0.2.14 # via # -c requirements/common-constraints.txt # prompt-toolkit +websocket-client==1.9.0 + # via + # -c requirements/common-constraints.txt + # kubernetes websockets==16.1.1 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in + # uvicorn xxhash==3.6.0 # via # -c requirements/common-constraints.txt # -r requirements/requirements.in +yarl==1.22.0 + # via + # -c requirements/common-constraints.txt + # aiohttp zipp==3.23.0 # via # -c requirements/common-constraints.txt diff --git a/requirements/common-constraints.txt b/requirements/common-constraints.txt index 4fd11b636ba..f1e10a0bf65 100644 --- a/requirements/common-constraints.txt +++ b/requirements/common-constraints.txt @@ -48,7 +48,9 @@ charset-normalizer==3.4.9 # -r requirements/requirements.in # requests chromadb==1.5.9 - # via -r requirements/requirements-help.in + # via + # -r requirements/requirements-help.in + # -r requirements/requirements.in click==8.3.1 # via # pip-tools diff --git a/requirements/requirements-help.in b/requirements/requirements-help.in index 1bbbacbc7d0..b0d20386377 100644 --- a/requirements/requirements-help.in +++ b/requirements/requirements-help.in @@ -1,4 +1,4 @@ -chromadb +chromadb>=1.5.9 # numpy is pulled in by chromadb's onnxruntime embedding stack numpy>=1.26.4 \ No newline at end of file diff --git a/requirements/requirements.in b/requirements/requirements.in index 0e3ba766d00..c83aca94357 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -33,6 +33,7 @@ beautifulsoup4>=4.13.4 pypandoc>=1.15 # storage/caching +chromadb>=1.5.9 diskcache>=5.6.3 # general string parsing diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 216ef6e1585..07b02a92df1 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -17,7 +17,7 @@ from cecli.io import InputOutput from cecli.mcp import McpServerManager from cecli.models import Model -from cecli.repo import GitRepo +from cecli.repo import GitRepo, GitRepoProxy from cecli.sendchat import sanity_check_messages from cecli.utils import GitTemporaryDirectory @@ -38,10 +38,11 @@ def setup(self, gpt35_model): self.mock_webbrowser = self.webbrowser_patcher.start() # Reset conversation system before each test ConversationService.get_chunks(self).reset() - # Reset FileSystemService singleton for test isolation + # Reset per-root singletons for test isolation from cecli.helpers.file_system import FileSystemService FileSystemService.reset_instance() + GitRepoProxy.reset_instances() yield # Cleanup after each test self.webbrowser_patcher.stop() diff --git a/tests/basic/test_file_system_service.py b/tests/basic/test_file_system_service.py new file mode 100644 index 00000000000..a154dab43e9 --- /dev/null +++ b/tests/basic/test_file_system_service.py @@ -0,0 +1,206 @@ +import gc +import os +import weakref + +import pytest + +from cecli.coders import Coder +from cecli.helpers.file_system import FileSystemService +from cecli.io import InputOutput +from cecli.repo import GitRepoProxy +from cecli.utils import GitTemporaryDirectory + + +@pytest.fixture(autouse=True) +def reset_registries(): + FileSystemService.reset_instance() + GitRepoProxy.reset_instances() + yield + FileSystemService.reset_instance() + GitRepoProxy.reset_instances() + + +class TestFileSystemServicePerRoot: + def test_for_root_caches_per_base_path(self): + with GitTemporaryDirectory() as root: + first = FileSystemService.for_root(root) + again = FileSystemService.for_root(root) + + assert first is again + assert FileSystemService._normalize_root(root) in FileSystemService._instances + + def test_for_root_distinct_for_distinct_base_paths(self): + with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b: + service_a = FileSystemService.for_root(root_a) + service_b = FileSystemService.for_root(root_b) + + assert service_a is not service_b + assert service_a.root.rstrip("/") == root_a.rstrip("/") + assert service_b.root.rstrip("/") == root_b.rstrip("/") + + def test_get_instance_returns_active_root(self): + with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b: + service_a = FileSystemService.for_root(root_a) + service_b = FileSystemService.for_root(root_b) + + assert FileSystemService.get_instance() is service_b + assert FileSystemService.get_instance() is not service_a + + def test_evict_removes_root(self): + with GitTemporaryDirectory() as root: + FileSystemService.for_root(root) + key = FileSystemService._normalize_root(root) + assert key in FileSystemService._instances + + FileSystemService.evict(root) + assert key not in FileSystemService._instances + + +class TestGitRepoProxyPerRoot: + def test_for_root_caches_per_base_path(self): + with GitTemporaryDirectory() as root: + io = InputOutput(pretty=False, yes=True) + proxy_a = GitRepoProxy.for_root(None, io, fnames=[root]) + proxy_b = GitRepoProxy.for_root(None, io, fnames=[root]) + + assert proxy_a is proxy_b + + def test_for_root_distinct_for_distinct_base_paths(self): + with GitTemporaryDirectory() as root_a, GitTemporaryDirectory() as root_b: + io = InputOutput(pretty=False, yes=True) + proxy_a = GitRepoProxy.for_root(None, io, fnames=[root_a]) + proxy_b = GitRepoProxy.for_root(None, io, fnames=[root_b]) + + assert proxy_a is not proxy_b + assert proxy_a.root.rstrip("/") == root_a.rstrip("/") + assert proxy_b.root.rstrip("/") == root_b.rstrip("/") + + def test_for_root_with_explicit_root_caches(self): + with GitTemporaryDirectory() as root: + io = InputOutput(pretty=False, yes=True) + proxy = GitRepoProxy.for_root(root, io) + + assert proxy.root.rstrip("/") == root.rstrip("/") + assert GitRepoProxy.for_root(root, io) is proxy + + +class TestAutomaticCleanup: + """The per-root service is released when the last coder sharing it dies.""" + + class _FakeCoder: + def __init__(self, root): + self.root = root + self.repo = GitRepoProxy.for_root( + None, InputOutput(pretty=False, yes=True), fnames=[root] + ) + self.fs = FileSystemService.for_root(root, repo=self.repo) + self._fs_key = FileSystemService._normalize_root(root) + FileSystemService._inc_ref(self._fs_key) + weakref.finalize(self, FileSystemService._release, self._fs_key) + + def test_last_coder_gc_evicts_service_and_repo(self): + with GitTemporaryDirectory() as root: + key = FileSystemService._normalize_root(root) + coder = self._FakeCoder(root) + + assert FileSystemService._refcounts.get(key) == 1 + assert key in FileSystemService._instances + assert key in GitRepoProxy._instances + + del coder + gc.collect() + + assert key not in FileSystemService._instances + assert key not in GitRepoProxy._instances + assert FileSystemService._refcounts.get(key, 0) == 0 + + def test_shared_root_kept_alive_until_last_coder_dies(self): + with GitTemporaryDirectory() as root: + key = FileSystemService._normalize_root(root) + coder_a = self._FakeCoder(root) + coder_b = self._FakeCoder(root) + + assert coder_a.fs is coder_b.fs + assert coder_a.repo is coder_b.repo + assert FileSystemService._refcounts.get(key) == 2 + + del coder_b + gc.collect() + assert key in FileSystemService._instances + assert key in GitRepoProxy._instances + assert FileSystemService._refcounts.get(key) == 1 + + del coder_a + gc.collect() + assert key not in FileSystemService._instances + assert key not in GitRepoProxy._instances + assert FileSystemService._refcounts.get(key, 0) == 0 + + +class TestCoderIntegration: + async def test_coder_gets_per_root_fs_and_repo(self, gpt35_model): + with GitTemporaryDirectory() as root_a: + io = InputOutput(pretty=False, yes=True) + coder_a = await Coder.create(gpt35_model, None, io) + with GitTemporaryDirectory() as root_b: + coder_b = await Coder.create(gpt35_model, None, io) + + assert coder_a.fs is not coder_b.fs + assert coder_a.repo is not coder_b.repo + assert coder_a.fs.repo is coder_a.repo + assert coder_b.fs.repo is coder_b.repo + assert coder_a.root.rstrip("/") == root_a.rstrip("/") + assert coder_b.root.rstrip("/") == root_b.rstrip("/") + + +class TestRootOverride: + """Validates the sub-agent root override and primary_root retention.""" + + async def test_root_kwarg_overrides_working_dir(self, gpt35_model): + with GitTemporaryDirectory() as root_a: + os.chdir(root_a) + nested = os.path.join(root_a, "nested") + os.makedirs(nested, exist_ok=True) + io = InputOutput(pretty=False, yes=True) + + coder = await Coder.create(gpt35_model, None, io, root=nested) + + assert os.path.normpath(coder.root) == os.path.normpath(nested) + assert coder.primary_root == coder.root + assert coder.fs is FileSystemService.for_root(nested, repo=coder.repo) + + async def test_primary_root_propagated_from_parent(self, gpt35_model): + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, yes=True) + parent = await Coder.create(gpt35_model, None, io) + with GitTemporaryDirectory() as root_b: + child = await Coder.create(from_coder=parent, root=root_b, io=io) + + assert child.primary_root.rstrip("/") == parent.root.rstrip("/") + assert child.root.rstrip("/") == root_b.rstrip("/") + assert child.fs is not parent.fs + + async def test_sub_agent_repo_scoped_to_its_root(self, gpt35_model): + with GitTemporaryDirectory(): + io = InputOutput(pretty=False, yes=True) + parent = await Coder.create(gpt35_model, None, io) + with GitTemporaryDirectory() as root_b: + child = await Coder.create(from_coder=parent, root=root_b, io=io) + + assert child.root.rstrip("/") == root_b.rstrip("/") + assert child.repo.root.rstrip("/") == root_b.rstrip("/") + assert child.fs.repo.root.rstrip("/") == root_b.rstrip("/") + + async def test_resolve_relative_to_primary_root(self, gpt35_model): + with GitTemporaryDirectory() as root_a: + os.chdir(root_a) + io = InputOutput(pretty=False, yes=True) + coder = await Coder.create(gpt35_model, None, io) + base = coder.primary_root or coder.root + + resolved = coder.resolve_relative_to_primary_root("skills/mine") + assert resolved == os.path.normpath(os.path.join(base, "skills/mine")) + + abs_path = "/tmp/abs-skill" + assert coder.resolve_relative_to_primary_root(abs_path) == abs_path + assert coder.resolve_relative_to_primary_root("") == "" diff --git a/tests/basic/test_skills.py b/tests/basic/test_skills.py index b7e24f5d082..9271f8f426e 100644 --- a/tests/basic/test_skills.py +++ b/tests/basic/test_skills.py @@ -35,7 +35,7 @@ def test_skills_manager_initialization(self): assert manager.directory_paths == [Path.home() / ".cecli" / "skills"] assert manager.include_list is None assert manager.exclude_list == set() - assert manager.git_root is None + assert manager.root is None # Test _loaded_skills is initialized as empty set assert manager._loaded_skills == set() @@ -51,15 +51,25 @@ def test_skills_manager_initialization(self): ["/tmp/test"], include_list=["skill1", "skill2"], exclude_list=["skill3"], - git_root="/tmp", + root="/tmp", ) - # "/tmp/test" + default home dir = 2 paths - assert len(manager.directory_paths) == 2 + # "/tmp/test" + local default + default home dir = 3 paths + assert len(manager.directory_paths) == 3 + assert Path("/tmp/test").resolve() in manager.directory_paths + assert (Path("/tmp") / ".cecli" / "skills").resolve() in manager.directory_paths assert manager.include_list == {"skill1", "skill2"} assert manager.exclude_list == {"skill3"} - assert manager.git_root == Path("/tmp").expanduser().resolve() + assert manager.root == Path("/tmp").expanduser().resolve() assert manager._loaded_skills == set() + def test_local_default_skills_dir(self): + """Local default {root}/.cecli/skills is included alongside global default.""" + manager = SkillsManager([], root="/tmp/local-root") + + paths = [str(p) for p in manager.directory_paths] + assert (Path("/tmp/local-root") / ".cecli" / "skills").resolve() in manager.directory_paths + assert str(Path.home() / ".cecli" / "skills") in paths + def test_create_and_parse_skill(self): """Test creating a skill and parsing its metadata.""" # Create a skill directory structure @@ -164,15 +174,15 @@ def test_resolve_skill_directories(self): assert len(paths) == 1 assert paths[0] == Path(self.temp_dir).resolve() - # Test with relative path and git root - paths = SkillsManager.resolve_skill_directories(["./test-dir"], git_root=self.temp_dir) + # Test with relative path and root + paths = SkillsManager.resolve_skill_directories(["./test-dir"], root=self.temp_dir) # Should not resolve because directory doesn't exist assert len(paths) == 0 # Create the directory and test again test_dir = Path(self.temp_dir) / "test-dir" test_dir.mkdir() - paths = SkillsManager.resolve_skill_directories(["./test-dir"], git_root=self.temp_dir) + paths = SkillsManager.resolve_skill_directories(["./test-dir"], root=self.temp_dir) assert len(paths) == 1 assert paths[0] == test_dir.resolve() diff --git a/tests/commands/test_open.py b/tests/commands/test_open.py new file mode 100644 index 00000000000..5c5510a6e98 --- /dev/null +++ b/tests/commands/test_open.py @@ -0,0 +1,118 @@ +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cecli.commands.open import OpenCommand + + +@pytest.fixture +def mock_coder(): + coder = MagicMock() + coder.uuid = "primary-uuid" + coder.tui = None + return coder + + +@pytest.fixture +def mock_io(): + io = MagicMock() + return io + + +@pytest.fixture +def mock_agent_service(): + with patch("cecli.commands.open.AgentService") as MockAgentService: + agent_service = MockAgentService.get_instance.return_value + MockAgentService.get_registry.return_value = { + "ws:app": MagicMock(metadata={"root": "/abs/app"}) + } + yield agent_service + + +class TestOpenCommand: + @pytest.mark.asyncio + async def test_execute_missing_args(self, mock_coder, mock_io): + await OpenCommand.execute(mock_io, mock_coder, "") + mock_io.tool_error.assert_called_once_with("Usage: /open ") + + @pytest.mark.asyncio + async def test_execute_invalid_path(self, mock_coder, mock_io): + with patch("cecli.commands.open.register_workspace_subagents", return_value=[]): + await OpenCommand.execute(mock_io, mock_coder, "app /no/such/path") + + open_path = Path("/no/such/path").expanduser() + mock_io.tool_error.assert_called_once_with( + f"Error: '{open_path}' is not a valid git repository or does not exist." + ) + + @pytest.mark.asyncio + async def test_execute_success_non_tui(self, mock_coder, mock_io, mock_agent_service): + info = MagicMock() + info.coder.uuid = "sub-uuid-1" + mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info)) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "app /abs/app") + + # spawn is non-blocking with no prompt; the sub-agent becomes the foreground agent. + mock_agent_service.spawn.assert_awaited_once_with( + "ws:app", prompt=None, parent=mock_coder, auto_reap=False, independent=True + ) + assert mock_agent_service.foreground_uuid == "sub-uuid-1" + mock_io.tool_output.assert_called_once_with( + "Opened workspace sub-agent 'ws:app' rooted at /abs/app." + ) + + @pytest.mark.asyncio + async def test_execute_success_tui(self, mock_coder, mock_io, mock_agent_service): + tui = MagicMock() + tui.get_keys_for.return_value = "" + mock_coder.tui = MagicMock(return_value=tui) + + info = MagicMock() + info.coder.uuid = "sub-uuid-1" + mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info)) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "app /abs/app") + + tui.call_from_thread.assert_called_once_with(tui._switch_to_container, "sub-uuid-1") + mock_io.tool_output.assert_called_once_with( + "Opened workspace sub-agent 'ws:app' rooted at /abs/app. Switch with " + ) + + @pytest.mark.asyncio + async def test_execute_ws_prefix(self, mock_coder, mock_io, mock_agent_service): + info = MagicMock() + info.coder.uuid = "sub-uuid-1" + mock_agent_service.spawn = AsyncMock(return_value=(MagicMock(), info)) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "ws:app /abs/app") + + mock_agent_service.spawn.assert_awaited_once_with( + "ws:app", prompt=None, parent=mock_coder, auto_reap=False, independent=True + ) + + @pytest.mark.asyncio + async def test_execute_spawn_error(self, mock_coder, mock_io, mock_agent_service): + mock_agent_service.spawn = AsyncMock(side_effect=RuntimeError("boom")) + + with patch("cecli.commands.open.register_workspace_subagents", return_value=["ws:app"]): + await OpenCommand.execute(mock_io, mock_coder, "app /abs/app") + + mock_io.tool_error.assert_called_once_with( + "Error opening workspace sub-agent 'ws:app': boom" + ) + + def test_get_help(self): + assert "(/open )" in OpenCommand.get_help() + + def test_get_completions(self): + with patch("cecli.commands.open.AgentService") as MockAgentService: + MockAgentService.get_registry.return_value = { + "ws:app": MagicMock(), + "worker": MagicMock(), + } + assert OpenCommand.get_completions(MagicMock(), MagicMock(), "") == ["ws:app"] diff --git a/tests/helpers/monorepo/LOCAL_WORKSPACE.md b/tests/helpers/monorepo/LOCAL_WORKSPACE.md deleted file mode 100644 index dd4311195cb..00000000000 --- a/tests/helpers/monorepo/LOCAL_WORKSPACE.md +++ /dev/null @@ -1,66 +0,0 @@ -# PR: Local `path:` projects for cecli workspaces - -## Summary - -Extends cecli’s existing **clone** workspace mode (`repo:` URLs under `~/.cecli/workspaces/`, paths like `project/main/file.py`) with **local** layout: multiple git roots on disk referenced by absolute `path:` in a repo-local config file. - -## Motivation - -IDE clients (e.g. BrightVision) open a **primary git repo** but need agent context across **sibling repos** without cloning into `~/.cecli/workspaces/`. Submodule-only setups are a different layout; this PR adds an explicit, reviewable config surface. - -## Config - -Place at the workspace root (walked up from any listed project path): - -```yaml -# .cecli.workspaces.yml -name: my-workspace -projects: - - name: app - path: /abs/path/to/app - primary: true - - name: lib - path: /abs/path/to/lib - readonly: true -``` - -Rules (enforced in `validate_config`): - -- Each project: `name` + **exactly one** of `path` or `repo` -- At most one `primary: true` - -## Path layout - -| Layout | Prefix | Example | -|--------|--------|---------| -| **local** (this PR) | `{project}/{file}` | `app/src/main.py` | -| **clone** (existing) | `{project}/main/{file}` | `app/main/src/main.py` | - -## Behavior changes - -| Area | Change | -|------|--------| -| `GitRepo.__init__` | Multiple git roots allowed when `.cecli.workspaces.yml` is found on a common ancestor | -| `get_workspace_files` | Local layout unions `git ls-files` from each `path:` root | -| `commit` | Local layout commits per underlying repo (`_commit_local_workspace`) | -| `abs_root_path` | Resolves prefixed paths to the correct project root | - -Clone workspaces and `.cecli-workspace.json` metadata are unchanged. - -## Tests - -- `tests/helpers/monorepo/test_config.py` — validation (`path` / `repo` XOR) -- `tests/helpers/monorepo/test_local_workspace.py` — helpers + `GitRepo` integration -- Existing `test_repomap_workspace.py`, `test_workspace.py`, etc. — still pass (clone layout) - -Run: - -```bash -pytest tests/helpers/monorepo -q -``` - -## Non-goals (follow-up PRs) - -- Auto-registering git submodules into the workspace registry -- Combining submodule `RepoSet` with local YAML in one facade -- New global config file formats (reuse `.cecli.workspaces.yml` only) diff --git a/tests/helpers/monorepo/test_config.py b/tests/helpers/monorepo/test_config.py deleted file mode 100644 index 0d85669753c..00000000000 --- a/tests/helpers/monorepo/test_config.py +++ /dev/null @@ -1,75 +0,0 @@ -import pytest - -from cecli.helpers.monorepo.config import validate_config - - -def test_validate_config_empty(): - # Should not raise - validate_config({}) - - -def test_validate_config_no_name(): - with pytest.raises(ValueError, match="Workspace configuration must include a 'name'"): - validate_config({"projects": []}) - - -def test_validate_config_invalid_project_missing_source(): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config({"name": "test", "projects": [{"name": "p1"}]}) - - -def test_validate_config_invalid_project_both_sources(): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config( - { - "name": "test", - "projects": [ - { - "name": "p1", - "path": "/tmp/p1", - "repo": "https://github.com/org/r.git", - } - ], - } - ) - - -def test_validate_config_path_project(): - validate_config( - { - "name": "local", - "projects": [{"name": "app", "path": "/abs/app", "primary": True}], - } - ) - - -def test_validate_config_duplicate_project(): - with pytest.raises(ValueError, match="Duplicate project name: p1"): - validate_config( - { - "name": "test", - "projects": [{"name": "p1", "repo": "url1"}, {"name": "p1", "repo": "url2"}], - } - ) - - -def test_validate_config_valid(): - config = {"name": "test", "projects": [{"name": "p1", "repo": "url1"}]} - validate_config(config) - assert config["projects"][0]["name"] == "p1" - - -def test_load_workspace_config_json_string(): - from cecli.helpers.monorepo.config import load_workspace_config - - config_str = '{"name": "json-ws", "projects": []}' - config = load_workspace_config(config_str) - assert config["name"] == "json-ws" - - -def test_load_workspace_config_yaml_string(): - from cecli.helpers.monorepo.config import load_workspace_config - - config_str = "name: yaml-ws\nprojects: []" - config = load_workspace_config(config_str) - assert config["name"] == "yaml-ws" diff --git a/tests/helpers/monorepo/test_config_active.py b/tests/helpers/monorepo/test_config_active.py deleted file mode 100644 index 95c27944e93..00000000000 --- a/tests/helpers/monorepo/test_config_active.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest - -from cecli.helpers.monorepo.config import load_workspace_config - - -def test_load_workspace_config_multiple_active_error(): - config_list = [ - {"name": "ws1", "active": True, "projects": []}, - {"name": "ws2", "active": True, "projects": []}, - ] - - # Mocking what would be in the config file/arg - with pytest.raises(ValueError, match="Multiple workspaces marked as active: ws1, ws2"): - # We simulate the loaded config being a list - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - load_workspace_config() - - -def test_load_workspace_config_select_by_name(): - config_list = [ - {"name": "ws1", "active": True, "projects": []}, - {"name": "ws2", "active": False, "projects": []}, - ] - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - # Should select ws2 even if ws1 is active - config = load_workspace_config(name="ws2") - assert config["name"] == "ws2" - - -def test_load_workspace_config_no_active_uses_first_if_only_one(): - config_list = [{"name": "ws1", "projects": []}] - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - config = load_workspace_config() - assert config["name"] == "ws1" - - -def test_load_workspace_config_single_dict_is_active_by_default(): - config_dict = {"name": "single-ws", "projects": []} - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_dict}))): - # Should work even if no name is passed and active is not set - config = load_workspace_config() - assert config["name"] == "single-ws" - - -def test_load_workspace_config_multiple_in_list_none_active_picks_none(): - config_list = [{"name": "ws1", "projects": []}, {"name": "ws2", "projects": []}] - - from unittest.mock import mock_open, patch - - import yaml - - with patch("pathlib.Path.exists", return_value=True): - with patch("builtins.open", mock_open(read_data=yaml.dump({"workspace": config_list}))): - # With multiple and none active, it should return empty if no name provided - config = load_workspace_config() - assert config == {} diff --git a/tests/helpers/monorepo/test_ignore_logic.py b/tests/helpers/monorepo/test_ignore_logic.py deleted file mode 100644 index d7d7393fc38..00000000000 --- a/tests/helpers/monorepo/test_ignore_logic.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -import shutil -import tempfile -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch - -import git - -from cecli.helpers.monorepo.workspace import WorkspaceManager -from cecli.io import InputOutput -from cecli.repo import GitRepo - - -class TestIgnoreLogic(unittest.TestCase): - def setUp(self): - self.test_dir = Path(tempfile.mkdtemp()).resolve() - self.old_cwd = os.getcwd() - os.chdir(self.test_dir) - - # Setup a dummy source ignore file - self.src_ignore = self.test_dir / "my_proj.ignore_src" - self.src_ignore.write_text("ignored_file.txt\n*.log\n") - - self.workspace_name = "test_ws" - # Use a local path for testing instead of ~/.cecli - self.workspace_root = (self.test_dir / "workspaces").resolve() - self.workspace_root.mkdir(parents=True, exist_ok=True) - self.ws_path = self.workspace_root / self.workspace_name - - self.config = { - "name": self.workspace_name, - "projects": [ - { - "name": "my_proj", - "repo": "https://github.com/example/repo", - "ignore": str(self.src_ignore), - } - ], - } - - def tearDown(self): - os.chdir(self.old_cwd) - if hasattr(self, "test_dir") and self.test_dir.exists(): - shutil.rmtree(self.test_dir) - - def test_ignore_file_copying(self): - # Test that WorkspaceManager.initialize copies the ignore file - wm = WorkspaceManager(self.workspace_name, self.config) - # Use our test ws_path - wm.path = self.ws_path - - with patch("cecli.helpers.monorepo.project.Project.initialize"): - wm.initialize() - - dest_ignore = self.ws_path / "my_proj.ignore" - self.assertTrue(dest_ignore.exists(), "Ignore file should be copied to workspace root") - self.assertEqual(dest_ignore.read_text(), self.src_ignore.read_text()) - - def test_repo_ignore_loading(self): - # Test that GitRepo loads the copied ignore file - wm = WorkspaceManager(self.workspace_name, self.config) - wm.path = self.ws_path - - with patch("cecli.helpers.monorepo.project.Project.initialize"): - wm.initialize() - - io = InputOutput() - # Create a dummy file in the workspace to trigger detection - dummy_file = self.ws_path / "my_proj" / "main" / "some_file.txt" - dummy_file.parent.mkdir(parents=True, exist_ok=True) - dummy_file.touch() - - # Mock git.Repo to avoid FileNotFoundError in GitRepo.__init__ - mock_repo = MagicMock(spec=git.Repo) - mock_repo.working_dir = str(self.ws_path) - mock_repo.__enter__.return_value = mock_repo - - # Patch _detect_workspace_path to return our test workspace path - with patch("git.Repo", return_value=mock_repo): - with patch("cecli.repo.GitRepo._detect_workspace_path", return_value=self.ws_path): - with patch("cecli.repo.GitRepo.init_repo"): - with patch( - "cecli.helpers.monorepo.config.load_workspace_config", - return_value=self.config, - ): - repo = GitRepo(io, fnames=[str(dummy_file)], git_dname=None) - - self.assertTrue(repo.is_workspace) - self.assertEqual(Path(repo.workspace_path), self.ws_path) - - # Verify ignore spec is loaded - repo._refresh_workspace_ignores() - self.assertIn("my_proj", repo.workspace_ignore_specs) - - spec = repo.workspace_ignore_specs["my_proj"] - self.assertTrue(spec.match_file("ignored_file.txt")) - self.assertTrue(spec.match_file("test.log")) - self.assertFalse(spec.match_file("keep.txt")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/helpers/monorepo/test_local_workspace.py b/tests/helpers/monorepo/test_local_workspace.py deleted file mode 100644 index db29df4b06d..00000000000 --- a/tests/helpers/monorepo/test_local_workspace.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Tests for repo-local workspaces (``path:`` git roots, ``.cecli.workspaces.yml``).""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest -import yaml - -from cecli.helpers.monorepo.config import load_workspace_config_file, validate_config -from cecli.helpers.monorepo.local_workspace import ( - find_workspace_config_file, - load_workspace_file, - primary_project, - project_path_prefix, - read_workspace_metadata, - resolve_workspace_file_path, - union_tracked_files, - write_workspace_metadata, -) -from cecli.io import InputOutput -from cecli.repo import GitRepo -from cecli.utils import make_repo - - -def _init_git_repo(path: Path, readme: str = "# repo\n") -> None: - make_repo(path) - readme_path = path / "README.md" - readme_path.write_text(readme, encoding="utf-8") - subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True) - subprocess.run( - ["git", "commit", "-m", "init", "--no-gpg-sign"], - cwd=path, - check=True, - capture_output=True, - ) - - -@pytest.fixture -def two_path_projects(tmp_path: Path): - """ - Workspace root with ``.cecli.workspaces.yml`` and two sibling git checkouts. - - Layout:: - - ws/ - .cecli.workspaces.yml - app/ (git) - lib/ (git) - """ - ws = tmp_path / "ws" - app = ws / "app" - lib = ws / "lib" - app.mkdir(parents=True) - lib.mkdir(parents=True) - _init_git_repo(app, "# app\n") - _init_git_repo(lib, "# lib\n") - - config = { - "name": "pair", - "projects": [ - {"name": "app", "path": str(app.resolve()), "primary": True}, - {"name": "lib", "path": str(lib.resolve())}, - ], - } - (ws / ".cecli.workspaces.yml").write_text( - yaml.dump(config, sort_keys=False), - encoding="utf-8", - ) - return ws, config, app, lib - - -class TestValidateConfigPathProjects: - def test_path_only_project_valid(self): - validate_config( - { - "name": "local", - "projects": [{"name": "app", "path": "/tmp/app", "primary": True}], - } - ) - - def test_repo_only_project_valid(self): - validate_config( - { - "name": "clone", - "projects": [{"name": "p1", "repo": "https://github.com/org/r.git"}], - } - ) - - def test_missing_path_and_repo(self): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config({"name": "test", "projects": [{"name": "p1"}]}) - - def test_both_path_and_repo(self): - with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): - validate_config( - { - "name": "test", - "projects": [ - { - "name": "p1", - "path": "/tmp/a", - "repo": "https://github.com/org/r.git", - } - ], - } - ) - - def test_multiple_primary(self): - with pytest.raises(ValueError, match="Only one project may be marked primary"): - validate_config( - { - "name": "test", - "projects": [ - {"name": "a", "path": "/a", "primary": True}, - {"name": "b", "path": "/b", "primary": True}, - ], - } - ) - - -class TestLocalWorkspaceHelpers: - def test_find_workspace_config_file_walks_up(self, two_path_projects): - ws, _config, app, _lib = two_path_projects - expected = (ws / ".cecli.workspaces.yml").resolve() - assert find_workspace_config_file(ws).resolve() == expected - # YAML lives at workspace root; project checkout is a subdirectory. - assert find_workspace_config_file(app).resolve() == expected - assert find_workspace_config_file(app / "README.md").resolve() == expected - - def test_load_workspace_config_file(self, two_path_projects): - ws, _config, _app, _lib = two_path_projects - loaded = load_workspace_config_file(ws / ".cecli.workspaces.yml") - assert loaded["name"] == "pair" - assert len(loaded["projects"]) == 2 - - def test_union_tracked_files(self, two_path_projects): - ws, config, _app, _lib = two_path_projects - files = union_tracked_files(ws, config, layout="local") - assert "app/README.md" in files - assert "lib/README.md" in files - - def test_resolve_workspace_file_path_prefixed(self, two_path_projects): - ws, config, _app, _lib = two_path_projects - resolved = resolve_workspace_file_path(ws, "lib/README.md", config, layout="local") - assert resolved is not None - git_root, abs_path, in_repo = resolved - assert in_repo == "README.md" - assert abs_path.name == "README.md" - assert git_root.name == "lib" - - def test_project_path_prefix_local_vs_clone(self): - proj = {"name": "app"} - assert project_path_prefix(proj, layout="local") == "app" - assert project_path_prefix(proj, layout="clone") == "app/main" - - def test_primary_project_explicit_and_implicit(self): - cfg = { - "projects": [ - {"name": "a", "path": "/a"}, - {"name": "b", "path": "/b", "primary": True}, - ] - } - assert primary_project(cfg)["name"] == "b" - - single = {"projects": [{"name": "only", "path": "/only"}]} - assert primary_project(single)["name"] == "only" - - def test_workspace_metadata_roundtrip(self, two_path_projects): - ws, config, _app, _lib = two_path_projects - write_workspace_metadata(ws, config, layout="local") - meta = read_workspace_metadata(ws) - assert meta is not None - loaded, layout = meta - assert layout == "local" - assert loaded["name"] == config["name"] - meta_path = ws / ".cecli" / ".workspace-meta.json" - assert meta_path.is_file() - on_disk = json.loads(meta_path.read_text(encoding="utf-8")) - assert on_disk.get("_layout") == "local" - - -class TestGitRepoLocalWorkspace: - def test_detects_local_workspace_and_unions_files(self, two_path_projects): - ws, _config, app, lib = two_path_projects - io = InputOutput(yes=True) - repo = GitRepo(io, [str(app / "README.md"), str(lib / "README.md")], None) - - assert repo.is_workspace - assert repo.workspace_layout == "local" - assert repo.workspace_path == ws.resolve() - - files = repo.get_workspace_files() - assert "app/README.md" in files - assert "lib/README.md" in files - - def test_abs_root_path_resolves_prefixed_path(self, two_path_projects): - _ws, _config, app, _lib = two_path_projects - io = InputOutput(yes=True) - repo = GitRepo(io, [str(app)], None) - - abs_path = Path(repo.abs_root_path("app/README.md")) - assert abs_path == (app / "README.md").resolve() - - def test_without_workspace_file_multi_repo_fails(self, tmp_path: Path): - root = tmp_path / "orphan" - a = root / "a" - b = root / "b" - a.mkdir(parents=True) - b.mkdir(parents=True) - _init_git_repo(a) - _init_git_repo(b) - io = InputOutput(yes=True) - with pytest.raises(FileNotFoundError): - GitRepo(io, [str(a / "README.md"), str(b / "README.md")], None) - - def test_load_workspace_file_defaults(self, tmp_path: Path): - path = tmp_path / ".cecli.workspaces.yml" - path.write_text("projects: []\n", encoding="utf-8") - loaded = load_workspace_file(path) - assert "name" in loaded - assert loaded["projects"] == [] - - -class TestGitRepoLocalWorkspaceNoYaml: - def test_single_repo_without_yaml_is_not_local_workspace(self, tmp_path: Path): - repo_dir = tmp_path / "solo" - repo_dir.mkdir() - _init_git_repo(repo_dir) - io = InputOutput(yes=True) - repo = GitRepo(io, [str(repo_dir)], None) - assert find_workspace_config_file(repo_dir) is None - assert getattr(repo, "workspace_layout", "clone") != "local" or not repo.is_workspace diff --git a/tests/helpers/monorepo/test_repomap_workspace.py b/tests/helpers/monorepo/test_repomap_workspace.py deleted file mode 100644 index 5649889e535..00000000000 --- a/tests/helpers/monorepo/test_repomap_workspace.py +++ /dev/null @@ -1,231 +0,0 @@ -import json -import subprocess -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from cecli.io import InputOutput -from cecli.repo import GitRepo - - -@pytest.fixture -def mock_workspace(tmp_path): - workspace_root = tmp_path / "workspace" - workspace_root.mkdir() - - # Project 1 - p1_dir = workspace_root / "p1" / "main" - p1_dir.mkdir(parents=True) - subprocess.run(["git", "init", "-b", "main"], cwd=p1_dir, check=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p1_dir, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=p1_dir, check=True) - (p1_dir / "file1.py").write_text("def func1(): pass") - subprocess.run(["git", "add", "file1.py"], cwd=p1_dir, check=True) - subprocess.run(["git", "commit", "-m", "p1 init"], cwd=p1_dir, check=True) - - # Project 2 - p2_dir = workspace_root / "p2" / "main" - p2_dir.mkdir(parents=True) - subprocess.run(["git", "init", "-b", "main"], cwd=p2_dir, check=True) - subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=p2_dir, check=True) - subprocess.run(["git", "config", "user.name", "Test"], cwd=p2_dir, check=True) - (p2_dir / "file2.py").write_text("def func2(): pass") - subprocess.run(["git", "add", "file2.py"], cwd=p2_dir, check=True) - subprocess.run(["git", "commit", "-m", "p2 init"], cwd=p2_dir, check=True) - - # Workspace metadata - config = { - "name": "test-ws", - "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}], - } - (workspace_root / ".cecli-workspace.json").write_text(json.dumps(config)) - - return workspace_root - - -def test_get_workspace_files(mock_workspace): - io = MagicMock(spec=InputOutput) - # Initialize GitRepo in p1 but it should detect the workspace - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - - # Force workspace_path for the test since _detect_workspace_path looks in ~/.cecli/workspaces - repo.workspace_path = mock_workspace - - files = repo.get_workspace_files() - assert "p1/main/file1.py" in files - assert "p2/main/file2.py" in files - assert len(files) == 2 - - -@pytest.mark.asyncio -async def test_coder_get_all_relative_files_workspace_integration(mock_workspace): - io = MagicMock() - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - repo.workspace_path = mock_workspace - - # Create a Coder-like object without full __init__ - class SimpleCoder: - def __init__(self, repo): - self.repo = repo - self.in_chat_files = [] - - def get_inchat_relative_files(self): - return self.in_chat_files - - def get_all_relative_files(self): - # Verify the logic we implemented in base_coder.py - if self.repo: - 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() - return files - - coder = SimpleCoder(repo) - files = coder.get_all_relative_files() - - assert "p1/main/file1.py" in files - assert "p2/main/file2.py" in files - assert len(files) == 2 - - -def test_repo_root_detection_for_repomap(mock_workspace): - io = MagicMock() - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - repo.workspace_path = mock_workspace - - # Verify that the logic we added to base_coder.py picks the workspace root - repo_root = ( - repo.workspace_path if (repo and getattr(repo, "workspace_path", None)) else Path(repo.root) - ) - - assert repo_root == mock_workspace - assert (repo_root / ".cecli-workspace.json").exists() - - -def test_project_branch_switching(mock_workspace): - # Setup: Create a new branch in p1 - p1_dir = mock_workspace / "p1" / "main" - subprocess.run(["git", "checkout", "-b", "feature/xyz"], cwd=p1_dir, check=True) - (p1_dir / "feature.py").write_text("print('feature')") - subprocess.run(["git", "add", "feature.py"], cwd=p1_dir, check=True) - subprocess.run(["git", "commit", "-m", "feature commit"], cwd=p1_dir, check=True) - - # Go back to master - subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True) - - # Define config with the new branch - config = { - "name": "test-ws", - "projects": [ - {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": False} - ], - } - - from cecli.helpers.monorepo.workspace import WorkspaceManager - - wm = WorkspaceManager("test-ws", config) - wm.path = mock_workspace # Override path for test - - # Re-initialize should trigger checkout - wm.initialize() - - # Verify p1 is now on feature/xyz - current_branch = subprocess.check_output( - ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8" - ).strip() - - assert current_branch == "feature/xyz" - assert (p1_dir / "feature.py").exists() - - -def test_project_use_current_branch_flag(mock_workspace): - # Setup: p1 is on master - p1_dir = mock_workspace / "p1" / "main" - subprocess.run(["git", "checkout", "main"], cwd=p1_dir, check=True) - - # Define config with a different branch but use_current_branch=True - config = { - "name": "test-ws", - "projects": [ - {"name": "p1", "repo": "url1", "branch": "feature/xyz", "use_current_branch": True} - ], - } - - from cecli.helpers.monorepo.workspace import WorkspaceManager - - wm = WorkspaceManager("test-ws", config) - wm.path = mock_workspace - - # Re-initialize should NOT trigger checkout - wm.initialize() - - # Verify p1 is still on main - current_branch = subprocess.check_output( - ["git", "-C", str(p1_dir), "rev-parse", "--abbrev-ref", "HEAD"], encoding="utf-8" - ).strip() - - assert current_branch == "main" - - -def test_get_workspace_files_caching(mock_workspace): - io = MagicMock() - repo = GitRepo(io, [], str(mock_workspace / "p1" / "main")) - repo.workspace_path = mock_workspace - - # First call - should populate cache - with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: - files1 = repo.get_workspace_files() - # Should have called rev-parse (2x) and ls-files (2x) - assert mock_run.call_count >= 4 - - # Second call - should use cache - with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: - files2 = repo.get_workspace_files() - # Should only call rev-parse to check SHAs (2x), NOT ls-files - # Total calls = number of projects (2) - assert mock_run.call_count == 2 - assert files1 == files2 - - # Modify a project - should invalidate cache - p1_dir = mock_workspace / "p1" / "main" - (p1_dir / "new_file.py").write_text("test") - subprocess.run(["git", "add", "new_file.py"], cwd=p1_dir, check=True) - subprocess.run(["git", "commit", "-m", "new file"], cwd=p1_dir, check=True) - - with patch("subprocess.check_output", wraps=subprocess.check_output) as mock_run: - files3 = repo.get_workspace_files() - # Should call rev-parse (2x) and then ls-files (2x) because SHAs changed - assert mock_run.call_count >= 4 - assert "p1/main/new_file.py" in files3 - - -@pytest.mark.asyncio -async def test_workspace_command(mock_workspace): - from cecli.commands.workspace import WorkspaceCommand - from cecli.io import InputOutput - - io = MagicMock(spec=InputOutput) - repo = MagicMock() - repo.workspace_path = mock_workspace - repo.root = str(mock_workspace / "p1" / "main") - - coder = MagicMock() - coder.repo = repo - - await WorkspaceCommand.execute(io, coder, None) - - # Check if io.print was called with workspace info - # WorkspaceCommand prints name, root, then projects - io.print.assert_any_call("Current Workspace: test-ws") - io.print.assert_any_call(f"Root Directory: {mock_workspace}") - - # Verify project details were printed - # The output includes project name, branch, remote, path - io.print.assert_any_call(" - p1:") - io.print.assert_any_call(" - p2:") diff --git a/tests/helpers/monorepo/test_workspace.py b/tests/helpers/monorepo/test_workspace.py deleted file mode 100644 index b1db56247b9..00000000000 --- a/tests/helpers/monorepo/test_workspace.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -from unittest.mock import patch - -import pytest - -from cecli.helpers.monorepo.workspace import WorkspaceManager - - -@pytest.fixture -def temp_workspace_root(tmp_path): - workspace_root = tmp_path / ".cecli" / "workspaces" - workspace_root.mkdir(parents=True) - - def mock_expand(path): - if path.startswith("~/.cecli/workspaces"): - return str(workspace_root / path.replace("~/.cecli/workspaces", "").lstrip("/")) - return path - - with patch("os.path.expanduser", side_effect=mock_expand): - yield workspace_root - - -def test_workspace_manager_exists(temp_workspace_root): - config = {"name": "test-ws", "projects": []} - wm = WorkspaceManager("test-ws", config) - assert not wm.exists() - - wm.path.mkdir(parents=True) - assert wm.exists() - - -@patch("cecli.helpers.monorepo.project.Project.initialize") -def test_workspace_manager_initialize(mock_proj_init, temp_workspace_root): - config = { - "name": "test-ws", - "projects": [{"name": "p1", "repo": "url1"}, {"name": "p2", "repo": "url2"}], - } - wm = WorkspaceManager("test-ws", config) - wm.initialize() - - assert wm.exists() - assert (wm.path / ".cecli-workspace.json").exists() - assert mock_proj_init.call_count == 2 - - with open(wm.path / ".cecli-workspace.json", "r") as f: - saved_config = json.load(f) - assert saved_config["name"] == "test-ws" - - -def test_workspace_manager_get_working_directory(temp_workspace_root): - wm = WorkspaceManager("test-ws", {}) - assert wm.get_working_directory() == temp_workspace_root / "test-ws" diff --git a/tests/helpers/test_llms_gemini_auth.py b/tests/helpers/test_llms_gemini_auth.py new file mode 100644 index 00000000000..53d5436bfcf --- /dev/null +++ b/tests/helpers/test_llms_gemini_auth.py @@ -0,0 +1,80 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from cecli.helpers.llms.domains.gemini import gemini_complete +from cecli.helpers.llms.providers.gemini import GeminiProvider + + +def test_gemini_provider_build_headers(): + provider = GeminiProvider() + headers = provider.build_headers( + resolved={"provider": "gemini"}, + key="my-secret-gemini-key", + family="gemini", + headers={"Custom-Header": "value"}, + ) + + assert headers["x-goog-api-key"] == "my-secret-gemini-key" + assert headers["Content-Type"] == "application/json" + assert headers["Custom-Header"] == "value" + assert "Authorization" not in headers + + +def test_gemini_provider_build_headers_no_key(): + provider = GeminiProvider() + headers = provider.build_headers( + resolved={"provider": "gemini"}, + key=None, + family="gemini", + headers={}, + ) + + assert "x-goog-api-key" not in headers + assert "Authorization" not in headers + + +@pytest.mark.asyncio +async def test_gemini_complete_sends_x_goog_api_key_header(): + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "responseId": "resp-123", + "candidates": [ + { + "content": {"parts": [{"text": "Hello world"}]}, + "finishReason": "STOP", + } + ], + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + + mock_make_client = MagicMock() + mock_make_client.return_value.__aenter__.return_value = mock_client + + with patch("cecli.helpers.llms.domains.gemini.make_client", mock_make_client): + resolved = { + "api_base": "https://generativelanguage.googleapis.com", + "route": "gemini-2.0-flash", + "model": "gemini/gemini-2.0-flash", + } + messages = [{"role": "user", "content": "Hi"}] + headers = {"x-goog-api-key": "my-secret-gemini-key"} + + resp = await gemini_complete( + resolved=resolved, + messages=messages, + tools=None, + key="my-secret-gemini-key", + headers=headers, + kwargs={}, + ) + + assert resp.choices[0].message.content == "Hello world" + mock_client.post.assert_called_once() + _, kwargs = mock_client.post.call_args + assert kwargs["headers"]["x-goog-api-key"] == "my-secret-gemini-key" + assert "Authorization" not in kwargs["headers"] + assert kwargs["params"] == {} diff --git a/tests/helpers/workspaces/conftest.py b/tests/helpers/workspaces/conftest.py new file mode 100644 index 00000000000..eeb10f72386 --- /dev/null +++ b/tests/helpers/workspaces/conftest.py @@ -0,0 +1,11 @@ +import pytest + +from cecli.helpers.agents.service import AgentService + + +@pytest.fixture(autouse=True) +def reset_agent_registry(): + """Clear the global sub-agent registry before/after each test.""" + AgentService._global_registry.clear() + yield + AgentService._global_registry.clear() diff --git a/tests/helpers/workspaces/test_config.py b/tests/helpers/workspaces/test_config.py new file mode 100644 index 00000000000..93aba38d095 --- /dev/null +++ b/tests/helpers/workspaces/test_config.py @@ -0,0 +1,115 @@ +import json + +import pytest +import yaml + +from cecli.helpers.workspaces.config import ( + find_active_workspace_name, + load_workspace_config, + validate_config, + workspace_layout, +) + + +def test_validate_config_empty(): + validate_config({}) + + +def test_validate_config_no_name(): + with pytest.raises(ValueError, match="must include a 'name'"): + validate_config({"projects": []}) + + +def test_validate_config_project_missing_source(): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config({"name": "test", "projects": [{"name": "p1"}]}) + + +def test_validate_config_project_both_sources(): + with pytest.raises(ValueError, match="exactly one of 'path' or 'repo'"): + validate_config( + { + "name": "test", + "projects": [{"name": "p1", "path": "/a", "repo": "https://github.com/o/r.git"}], + } + ) + + +def test_validate_config_path_project(): + validate_config( + {"name": "local", "projects": [{"name": "app", "path": "/abs/app", "primary": True}]} + ) + + +def test_validate_config_repo_project(): + validate_config( + {"name": "clone", "projects": [{"name": "app", "repo": "https://github.com/o/r.git"}]} + ) + + +def test_validate_config_duplicate_project(): + with pytest.raises(ValueError, match="Duplicate project name: p1"): + validate_config( + { + "name": "test", + "projects": [{"name": "p1", "path": "/a"}, {"name": "p1", "path": "/b"}], + } + ) + + +def test_validate_config_multiple_primary(): + with pytest.raises(ValueError, match="Only one project may be marked primary"): + validate_config( + { + "name": "test", + "projects": [ + {"name": "a", "path": "/a", "primary": True}, + {"name": "b", "path": "/b", "primary": True}, + ], + } + ) + + +def test_workspace_layout_local(): + config = {"name": "local", "projects": [{"name": "app", "path": "/a"}]} + assert workspace_layout(config) == "local" + + +def test_workspace_layout_clone(): + config = {"name": "clone", "projects": [{"name": "app", "repo": "https://github.com/o/r.git"}]} + assert workspace_layout(config) == "clone" + + +def test_workspace_layout_explicit_field_overrides_inference(): + config = {"name": "ws", "layout": "clone", "projects": [{"name": "app", "path": "/a"}]} + assert workspace_layout(config) == "clone" + + +def test_load_workspace_config_json_string(): + config = load_workspace_config(json.dumps({"name": "json-ws", "projects": []})) + assert config["name"] == "json-ws" + + +def test_load_workspace_config_yaml_string(): + config = load_workspace_config("name: yaml-ws\nprojects: []") + assert config["name"] == "yaml-ws" + + +def test_load_workspace_config_select_by_name(): + config_list = [{"name": "ws1", "active": True, "projects": []}, {"name": "ws2", "projects": []}] + config = load_workspace_config(yaml.dump({"workspaces": config_list}), name="ws2") + assert config["name"] == "ws2" + + +def test_load_workspace_config_multiple_active_error(): + config_list = [ + {"name": "ws1", "active": True, "projects": []}, + {"name": "ws2", "active": True, "projects": []}, + ] + with pytest.raises(ValueError, match="Multiple workspaces marked as active"): + load_workspace_config(yaml.dump({"workspaces": config_list})) + + +def test_find_active_workspace_name(): + config_list = [{"name": "ws1", "active": True, "projects": []}, {"name": "ws2", "projects": []}] + assert find_active_workspace_name(yaml.dump({"workspaces": config_list})) == "ws1" diff --git a/tests/helpers/workspaces/test_paths.py b/tests/helpers/workspaces/test_paths.py new file mode 100644 index 00000000000..f0bf61634f3 --- /dev/null +++ b/tests/helpers/workspaces/test_paths.py @@ -0,0 +1,116 @@ +import os +import subprocess +import tempfile +from pathlib import Path + +from cecli.helpers.workspaces.paths import ( + project_path, + resolve_workspace_file_path, + union_tracked_files, +) +from cecli.utils import make_repo + + +def _make_git_repo(base: Path, name: str, files=None) -> Path: + root = base / name + os.makedirs(root, exist_ok=True) + make_repo(root) + if files: + for rel in files: + f = root / rel + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(rel, encoding="utf-8") + subprocess.check_call(["git", "-C", str(root), "add", rel]) + return root.resolve() + + +def test_project_path_returns_git_root_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + root = _make_git_repo(base, "app") + assert project_path(Path("."), {"name": "app", "path": str(root)}, layout="local") == root + + +def test_project_path_clone_returns_main_checkout(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + clone_root = _make_git_repo(base, "app/main") + assert ( + project_path(base, {"name": "app", "repo": "https://x/r.git"}, layout="clone") + == clone_root + ) + + +def test_project_path_none_for_non_git(): + with tempfile.TemporaryDirectory() as td: + plain = Path(td) / "plain" + os.makedirs(plain, exist_ok=True) + assert project_path(Path("."), {"name": "p", "path": str(plain)}, layout="local") is None + + +def test_project_path_none_for_missing(): + assert project_path(Path("."), {"name": "p", "path": "/does/not/exist"}, layout="local") is None + + +def test_resolve_workspace_file_path_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + config = {"name": "ws", "projects": [{"name": "app", "path": str(app)}]} + resolved = resolve_workspace_file_path(base, "app/src/main.py", config, layout="local") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert git_root == app + assert abs_path == app / "src" / "main.py" + assert in_repo == "src/main.py" + + +def test_resolve_workspace_file_path_clone(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + clone_root = _make_git_repo(base, "app/main") + config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + resolved = resolve_workspace_file_path(base, "app/main/src/main.py", config, layout="clone") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert git_root == clone_root + assert abs_path == clone_root / "src" / "main.py" + assert in_repo == "src/main.py" + + +def test_resolve_workspace_file_path_primary_fallback(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + config = {"name": "ws", "projects": [{"name": "app", "path": str(app), "primary": True}]} + resolved = resolve_workspace_file_path(base, "some/file.txt", config, layout="local") + assert resolved is not None + git_root, abs_path, in_repo = resolved + assert git_root == app + assert in_repo == "some/file.txt" + + +def test_union_tracked_files_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app", files=["src/main.py", "README.md"]) + lib = _make_git_repo(base, "lib", files=["lib.py"]) + config = { + "name": "ws", + "projects": [{"name": "app", "path": str(app)}, {"name": "lib", "path": str(lib)}], + } + + files = union_tracked_files(base, config, layout="local") + assert "app/src/main.py" in files + assert "app/README.md" in files + assert "lib/lib.py" in files + + +def test_union_tracked_files_clone(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + _make_git_repo(base, "app/main", files=["src/main.py"]) + config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + + files = union_tracked_files(base, config, layout="clone") + assert "app/main/src/main.py" in files diff --git a/tests/helpers/workspaces/test_subagents.py b/tests/helpers/workspaces/test_subagents.py new file mode 100644 index 00000000000..11b1b632a63 --- /dev/null +++ b/tests/helpers/workspaces/test_subagents.py @@ -0,0 +1,106 @@ +import os +import tempfile +from pathlib import Path + +from cecli.helpers.agents.service import AgentService +from cecli.helpers.workspaces.subagents import register_workspace_subagents +from cecli.utils import make_repo + + +def _make_git_repo(base: Path, name: str) -> Path: + root = base / name + os.makedirs(root, exist_ok=True) + make_repo(root) + return root.resolve() + + +def test_register_workspace_subagents_creates_ws_agents_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + lib = _make_git_repo(base, "lib") + + config = { + "name": "ws", + "projects": [ + {"name": "app", "path": str(app), "primary": True}, + {"name": "lib", "path": str(lib)}, + ], + } + + registered = register_workspace_subagents(config) + assert "ws:app" in registered + assert "ws:lib" in registered + + ws_app = AgentService.get_registry()["ws:app"] + assert ws_app.name == "ws:app" + assert ws_app.metadata["root"] == str(app) + assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True + + ws_lib = AgentService.get_registry()["ws:lib"] + assert ws_lib.metadata["root"] == str(lib) + assert ws_lib.metadata["agent-config"]["allow_nested_delegation"] is True + + +def test_register_workspace_subagents_clone_root(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + clone_root = _make_git_repo(base, "app/main") + + config = {"name": "ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + registered = register_workspace_subagents(config, workspace_root=base) + assert "ws:app" in registered + + ws_app = AgentService.get_registry()["ws:app"] + assert ws_app.metadata["root"] == str(clone_root) + assert ws_app.metadata["layout"] == "clone" + assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True + + +def test_register_workspace_subagents_project_metadata_frontmatter(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + + config = { + "name": "ws", + "projects": [ + { + "name": "app", + "path": str(app), + "metadata": { + "model": "", + "auto_reap": False, + "name": "hijack", + "root": "/somewhere/else", + "description": "Custom ws agent", + "tools_includelist": ["readfile", "grep", "yield"], + }, + } + ], + } + + registered = register_workspace_subagents(config) + assert "ws:app" in registered + + ws_app = AgentService.get_registry()["ws:app"] + assert ws_app.name == "ws:app" + assert ws_app.model == "" + assert ws_app.auto_reap is False + # ``root``, ``name`` and ``description`` are always derived from the + # workspace/project definition and cannot be overridden by ``metadata``. + assert ws_app.metadata["root"] == str(app) + assert ws_app.metadata.get("name") != "hijack" + assert ws_app.metadata["description"].startswith("Workspace sub-agent for project 'app'") + assert ws_app.metadata["tools_includelist"] == ["readfile", "grep", "yield"] + assert ws_app.metadata["agent-config"]["allow_nested_delegation"] is True + + +def test_register_workspace_subagents_skips_non_git_project(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + plain = base / "plain" + os.makedirs(plain, exist_ok=True) + config = {"name": "ws", "projects": [{"name": "plain", "path": str(plain)}]} + registered = register_workspace_subagents(config) + assert registered == [] diff --git a/tests/helpers/workspaces/test_workspace.py b/tests/helpers/workspaces/test_workspace.py new file mode 100644 index 00000000000..05334b5efa8 --- /dev/null +++ b/tests/helpers/workspaces/test_workspace.py @@ -0,0 +1,58 @@ +import os +import tempfile +from pathlib import Path + +from cecli.helpers.workspaces.workspace import WorkspaceManager +from cecli.utils import make_repo + + +def _make_git_repo(base: Path, name: str) -> Path: + root = base / name + os.makedirs(root, exist_ok=True) + make_repo(root) + return root.resolve() + + +def test_get_working_directory_returns_primary_project(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + lib = _make_git_repo(base, "lib") + config = { + "name": "ws", + "projects": [ + {"name": "app", "path": str(app), "primary": True}, + {"name": "lib", "path": str(lib)}, + ], + } + + manager = WorkspaceManager("ws", config, root=base) + assert manager.path == base.resolve() + assert manager.get_working_directory() == app + + +def test_default_root_falls_back_to_primary_project(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + app = _make_git_repo(base, "app") + config = {"name": "ws", "projects": [{"name": "app", "path": str(app), "primary": True}]} + + manager = WorkspaceManager("ws", config) + assert manager.path == app + + +def test_exists_and_initialize_local(): + with tempfile.TemporaryDirectory() as td: + base = Path(td) + manager = WorkspaceManager("ws", {"name": "ws", "projects": []}, root=base) + manager.initialize() + assert manager.exists() + assert (base / ".cecli" / ".workspace-meta.json").is_file() + + +def test_clone_workspace_uses_cecli_workspaces_dir(): + config = {"name": "my-ws", "projects": [{"name": "app", "repo": "https://x/r.git"}]} + manager = WorkspaceManager("my-ws", config) + assert manager.layout == "clone" + assert manager.path == Path(os.path.expanduser("~/.cecli/workspaces/my-ws")) + assert manager.get_working_directory() == manager.path diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py index 29026791e80..2084906b5a1 100644 --- a/tests/subagents/test_service.py +++ b/tests/subagents/test_service.py @@ -130,6 +130,20 @@ def test_build_registry(self, temp_dir): assert "reviewer" in AgentService._global_registry AgentService._global_registry = {} + def test_build_registry_resolves_relative_path_against_root(self, temp_dir): + """Relative sub-agent directories resolve against the provided ``root``.""" + AgentService._global_registry = {} + + subagents_dir = temp_dir / "subagents" + subagents_dir.mkdir() + md_file = subagents_dir / "reviewer.md" + md_file.write_text("---\n" "name: reviewer\n" "---\n" "Review code.") + + # ``subagents`` is relative — it must be resolved against ``root``. + AgentService.build_registry(["subagents"], root=str(temp_dir)) + assert "reviewer" in AgentService._global_registry + AgentService._global_registry = {} + def test_build_registry_skips_missing_dir(self): """Non-existent directories are skipped silently (default dirs still scanned).""" AgentService._global_registry = {} @@ -144,6 +158,19 @@ def test_build_registry_skips_missing_dir(self): # Verify the nonexistent path didn't cause an error - default entries are fine assert AgentService._global_registry is not None + def test_build_registry_includes_local_default_dir(self, temp_dir): + """build_registry scans {root}/.cecli/subagents as a local default.""" + AgentService._global_registry = {} + + local_dir = temp_dir / ".cecli" / "subagents" + local_dir.mkdir(parents=True) + md_file = local_dir / "localagent.md" + md_file.write_text("---\nname: localagent\n---\nLocal agent.") + + AgentService.build_registry([], root=str(temp_dir)) + assert "localagent" in AgentService._global_registry + AgentService._global_registry = {} + # ================================================================== # # Instance initialization diff --git a/tests/tools/test_grep.py b/tests/tools/test_grep.py index 9030f883468..4a630b6eb04 100644 --- a/tests/tools/test_grep.py +++ b/tests/tools/test_grep.py @@ -60,3 +60,103 @@ def test_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monk assert "total_files" in op["_"] assert isinstance(op["_"]["total_files"], int) coder.io.tool_error.assert_not_called() + + +@pytest.mark.skipif(shutil.which("powershell") is None, reason="powershell is required") +@pytest.mark.parametrize( + "search_term", + [ + "--pattern", + "-pattern", + ], +) +def test_powershell_dash_prefixed_pattern_is_searched_literally(search_term, tmp_path, monkeypatch): + sample = tmp_path / "example.txt" + sample.write_text(f"flag {search_term} should be found\n") + + coder = SimpleNamespace( + repo=SimpleNamespace(root=str(tmp_path)), + io=SimpleNamespace( + tool_error=Mock(), + tool_output=Mock(), + tool_warning=Mock(), + ), + verbose=False, + root=str(tmp_path), + tui=lambda: None, + ) + + monkeypatch.setattr( + grep.Tool, "_find_search_tool", lambda: ("powershell", shutil.which("powershell")) + ) + + result = grep.Tool.execute( + coder, + searches=[ + { + "pattern": search_term, + "file_glob": "*.txt", + "directory": ".", + "use_regex": False, + "case_insensitive": False, + "context_before": 0, + "context_after": 0, + } + ], + ) + + response_dict = result.to_dict() + operations = response_dict["result"] + assert len(operations) == 1 + op = operations[0] + assert op["_"]["pattern"] == search_term + assert op["_"]["error"] is None + assert op["_"]["total_files"] >= 1 + assert op["_"]["total_matches"] >= 1 + assert any("example.txt" in f["file"] for f in op["_"]["files"]) + coder.io.tool_error.assert_not_called() + + +@pytest.mark.skipif(shutil.which("powershell") is None, reason="powershell is required") +def test_powershell_counts_and_context(tmp_path, monkeypatch): + sample = tmp_path / "sample.txt" + sample.write_text("alpha\nbeta\nalpha\ngamma alpha\n") + + coder = SimpleNamespace( + repo=SimpleNamespace(root=str(tmp_path)), + io=SimpleNamespace( + tool_error=Mock(), + tool_output=Mock(), + tool_warning=Mock(), + ), + verbose=False, + root=str(tmp_path), + tui=lambda: None, + ) + + monkeypatch.setattr( + grep.Tool, "_find_search_tool", lambda: ("powershell", shutil.which("powershell")) + ) + + result = grep.Tool.execute( + coder, + searches=[ + { + "pattern": "alpha", + "file_glob": "*.txt", + "directory": ".", + "use_regex": False, + "case_insensitive": True, + "context_before": 1, + "context_after": 1, + } + ], + ) + + response_dict = result.to_dict() + op = response_dict["result"][0] + assert op["_"]["error"] is None + assert op["_"]["total_matches"] >= 3 + file_entry = next(f for f in op["_"]["files"] if f["file"] == "sample.txt") + assert file_entry["match_count"] >= 3 + coder.io.tool_error.assert_not_called() diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index f9ba9e49952..dd926e9df68 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -184,6 +184,19 @@ def test_skill_and_mcp_tools_in_context_manager(self): assert "load_mcp" in params, "context_manager should have load_mcp param" assert "remove_mcp" in params, "context_manager should have remove_mcp param" + def test_build_registry_scans_default_tools_dirs(self, tmp_path): + """build_registry scans the global and local default tools directories.""" + local_dir = tmp_path / ".cecli" / "tools" + local_dir.mkdir(parents=True) + (local_dir / "custom_tool.py").write_text( + "class Tool:\n" + " NORM_NAME = 'custom_tool'\n" + " SCHEMA = {'function': {'name': 'custom_tool', 'description': 'desc'}}\n" + ) + + registry = ToolRegistry.build_registry({}, root=str(tmp_path)) + assert "custom_tool" in registry + if __name__ == "__main__": # Run tests if executed directly