From 39657f380b1d93c355d67d78110d83bf5e02e4f8 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 28 Oct 2026 21:52:24 -0400 Subject: [PATCH 01/13] Centralize agent tree in its own file and exempt workflow agents from the main conversation --- cecli/coders/agent_coder.py | 89 ++++++--- cecli/coders/base_coder.py | 7 +- cecli/commands/agent_tree.py | 47 +++-- cecli/helpers/agents/service.py | 16 +- cecli/helpers/agents/tree.py | 271 +++++++++++++++++++++++++++ cecli/tools/broadcast.py | 37 ++-- cecli/tools/delegate.py | 8 + cecli/tui/widgets/floating_panels.py | 14 +- tests/commands/test_agent_tree.py | 83 ++++++++ tests/subagents/test_tree.py | 112 +++++++++++ tests/tui/test_floating_panels.py | 7 +- 11 files changed, 624 insertions(+), 67 deletions(-) create mode 100644 cecli/helpers/agents/tree.py create mode 100644 tests/commands/test_agent_tree.py create mode 100644 tests/subagents/test_tree.py diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 8ad410dd529..25532f3fc9b 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -17,6 +17,7 @@ from cecli.change_tracker import ChangeTracker from cecli.helpers import nested, responses from cecli.helpers.agents.service import AgentService +from cecli.helpers.agents.tree import AgentTree from cecli.helpers.background_commands import BackgroundCommandManager from cecli.helpers.conversation import ConversationService, MessageTag from cecli.helpers.similarity import ( @@ -821,7 +822,10 @@ def get_environment_info(self): 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" + # Workflow agents run in an isolated tree and must not see the + # primary agent (nor it them). + if AgentTree.is_visible(self_uuid, primary_uuid): + result += f"- Primary agent: primary ({primary_uuid})\n" if self.repo: try: rel_repo_dir = self.repo.get_rel_repo_dir() @@ -1812,6 +1816,11 @@ def get_sub_agents_context(self): return None try: registry = AgentService.get_registry() + # Workflow agents live in isolated trees; the primary must not see + # them as delegatable sub-agent types. + registry = { + name: config for name, config in registry.items() if not name.startswith("wf:") + } if not registry: return None @@ -1842,9 +1851,11 @@ def get_sub_agent_states(self): 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. + Finished/errored sub-agents are excluded. Agents inside a workflow tree + see every active agent in that tree rather than only their own children. + 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) @@ -1868,34 +1879,50 @@ def get_sub_agent_states(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) - dependent_children = [ - info - for info in children - if not info.independent - and not info.detached - and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR) - ] - - # 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 + visible_uuids = AgentTree.get_visible_uuids(originator_uuid) + workflow_root = AgentTree.get_workflow_root(originator_uuid) + + if workflow_root is not None: + # Inside a workflow tree, every active agent in that tree is + # visible: the workflow root, its children and their descendants. + dependent_children = [ + info + for info in service.sub_agents.values() + if str(info.coder.uuid) != originator_uuid + and str(info.coder.uuid) in visible_uuids and not info.detached - 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) + and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR) + ] + independent_agents = [] + else: + # Outside a workflow tree, dependent agents are the coder's + # direct children; independent agents are the others reachable + # through Broadcast in the same visibility domain. + children = service.get_children(self) + dependent_children = [ + info + for info in children + if not info.independent + and not info.detached + and str(info.coder.uuid) in visible_uuids + and info.status not in (SubAgentStatus.FINISHED, SubAgentStatus.ERROR) + ] + + 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 not info.detached + and str(info.coder.uuid) != originator_uuid + and str(info.coder.uuid) in visible_uuids + 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 and not independent_agents: return None diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index e9d6db300b7..26ca44331a6 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -1054,8 +1054,11 @@ def get_announcements(self): from cecli.helpers.agents.service import AgentService registry = AgentService.get_registry() - if registry: - capabilities["Subagents"] = sorted(registry.keys()) + # Workflow agents belong to isolated trees, so they are not + # advertised as general-purpose subagent types. + subagents = sorted(name for name in registry if not name.startswith("wf:")) + if subagents: + capabilities["Subagents"] = subagents except Exception: pass diff --git a/cecli/commands/agent_tree.py b/cecli/commands/agent_tree.py index 9e8cd96dfdc..951dc890a71 100644 --- a/cecli/commands/agent_tree.py +++ b/cecli/commands/agent_tree.py @@ -2,6 +2,7 @@ from cecli.commands.utils.base_command import BaseCommand from cecli.helpers.agents.service import AgentService, SubAgentInfo, SubAgentStatus +from cecli.helpers.agents.tree import AgentTree class TreeNode: @@ -30,17 +31,15 @@ async def execute(cls, io, coder, args, **kwargs): return # Collect all agents: primary coder + sub-agents from sub_agents dict - all_infos: List[SubAgentInfo] = [] - - # Primary agent as a synthetic SubAgentInfo primary_coder = agent_service.coder + primary_uuid = str(primary_coder.uuid) primary_info = SubAgentInfo( name="primary", coder=primary_coder, parent_uuid=str(primary_coder.parent_uuid or ""), status=SubAgentStatus.RUNNING, ) - all_infos.append(primary_info) + all_infos: List[SubAgentInfo] = [primary_info] # Sub-agents from the service's sub_agents dict for info in agent_service.sub_agents.values(): @@ -51,26 +50,38 @@ async def execute(cls, io, coder, args, **kwargs): io.tool_output("No active sub-agents found.") return - root_nodes = cls._build_tree(all_infos) + # The shared tree is process-wide, so structure the display from the + # primary's subtree only. + infos_by_uuid = {str(info.coder.uuid): info for info in all_infos} + subtree = AgentTree.get_subtree(primary_uuid) + if not subtree: + subtree = list(infos_by_uuid) + + root_nodes = cls._build_tree(primary_uuid, infos_by_uuid, subtree) tree_output = cls._render_tree(root_nodes) io.tool_output(tree_output) @classmethod - def _build_tree(cls, all_agents: List[SubAgentInfo]) -> List[TreeNode]: - """Build the agent tree from a flat list of agents.""" - nodes = {str(agent.coder.uuid): TreeNode(agent) for agent in all_agents} - root_nodes = [] - - for agent in all_agents: - parent_uuid = str(agent.coder.parent_uuid) - if parent_uuid and parent_uuid in nodes: - parent_node = nodes[parent_uuid] - child_node = nodes[str(agent.coder.uuid)] - parent_node.add_child(child_node) + def _build_tree(cls, root_uuid, infos, subtree_uuids) -> List[TreeNode]: + """Build the display tree from the shared :class:`AgentTree`.""" + nodes = {uuid: TreeNode(infos[uuid]) for uuid in subtree_uuids if uuid in infos} + root = nodes.get(root_uuid) + if root is None: + return [] + + for uuid, node in nodes.items(): + if uuid == root_uuid: + continue + + parent = nodes.get(AgentTree.get_parent(uuid)) + if parent is not None: + parent.add_child(node) else: - root_nodes.append(nodes[str(agent.coder.uuid)]) + # Keep a node reachable even if its parent fell outside the + # session subtree (e.g. a spliced node left over). + root.add_child(node) - return root_nodes + return [root] @classmethod def _render_tree(cls, nodes: List[TreeNode]) -> str: diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index 12827eb70f6..a920690b189 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -16,6 +16,7 @@ import cecli.models as models from cecli.helpers import nested +from cecli.helpers.agents.tree import AgentTree from cecli.helpers.coroutines import fire_and_forget logger = logging.getLogger(__name__) @@ -351,6 +352,11 @@ def __init__(self, coder) -> None: # Register the primary coder in the global uuid map if hasattr(coder, "uuid"): self._uuid_coder_map[str(coder.uuid)] = weakref.ref(coder) + AgentTree.register( + str(coder.uuid), + parent_uuid=getattr(coder, "parent_uuid", None) or None, + name="primary", + ) # uuid -> SubAgentInfo self.sub_agents: Dict[str, SubAgentInfo] = {} # Ordered list of sub-agent UUIDs for LRU reap @@ -581,6 +587,7 @@ def _cleanup_sub_agent(self, agent_uuid: str) -> None: # not the parent's service instance. The parent's instance is cleaned # up separately in cleanup_all_for_parent(). self._uuid_coder_map.pop(info.coder.uuid, None) + AgentTree.unregister(info.coder.uuid) self.destroy_instance(info.coder.uuid) def _check_max_sub_agents(self) -> None: @@ -786,8 +793,13 @@ async def _create_sub_agent_coder( with self._get_lock(self._spawn_locks, parent_coder.uuid): self._check_max_sub_agents() - # Register in global coder lookup + # Register in global coder lookup and the visibility tree self._uuid_coder_map[new_uuid] = weakref.ref(new_coder) + AgentTree.register( + str(new_coder.uuid), + parent_uuid=str(parent_coder.uuid), + name=name, + ) info = SubAgentInfo( name=name, @@ -882,6 +894,7 @@ async def switch_sub_agent_coder(self, info: SubAgentInfo, switch, preproc=True) kwargs["summarize_from_coder"] = False new_coder = await Coder.create(**kwargs) + AgentTree.replace(old_coder.uuid, new_coder.uuid) new_coder.prompt_queue = list(old_coder.prompt_queue) new_coder._queue_counter = old_coder._queue_counter @@ -1389,6 +1402,7 @@ def cleanup_all_for_parent(self) -> None: """Clean up all sub-agents when the parent session ends.""" for uuid in list(self.sub_agents.keys()): self._cleanup_sub_agent(uuid) + AgentTree.unregister(self.coder.uuid) # Clean up lock pools to prevent memory leaks self._spawn_locks.pop(self.coder.uuid, None) self._conversation_locks.pop(self.coder.uuid, None) diff --git a/cecli/helpers/agents/tree.py b/cecli/helpers/agents/tree.py new file mode 100644 index 00000000000..d7dc862bbed --- /dev/null +++ b/cecli/helpers/agents/tree.py @@ -0,0 +1,271 @@ +"""Parent/child tree of tracked agent UUIDs. + +The tree is the single source of truth for agent visibility. A workflow agent +(``wf:``) roots an isolated subtree: it cannot see the primary agent or any +agent outside its own subtree, and the primary cannot see it. All other agents +share the primary's visibility domain. +""" + +from __future__ import annotations + +import threading +from typing import Any, Dict, List, Optional, Set + +_PARENT = "parent" +_CHILDREN = "children" +_NAME = "name" +_WORKFLOW = "workflow" + + +class AgentTree: + """Process-wide tree of agent UUIDs used to resolve visibility. + + Nodes are registered when a coder is created and removed when it is reaped. + Every lookup tolerates unknown UUIDs so callers never need to guard calls. + """ + + _nodes: Dict[str, Dict[str, Any]] = {} + _lock = threading.RLock() + + # ------------------------------------------------------------------ # + # Mutation + # ------------------------------------------------------------------ # + + @classmethod + def register( + cls, + agent_uuid: str, + parent_uuid: Optional[str] = None, + name: Optional[str] = None, + ) -> None: + """Add or reparent *agent_uuid*, inheriting its parent's workflow domain. + + A ``wf:`` name starts a new workflow domain rooted at the node itself. + """ + agent_key = str(agent_uuid) + parent_key = str(parent_uuid) if parent_uuid else None + if parent_key == agent_key: + parent_key = None + + with cls._lock: + node = cls._ensure_node(agent_key) + if name is not None: + node[_NAME] = name + + workflow = agent_key if cls._is_workflow_name(node[_NAME]) else None + if workflow is None and parent_key: + parent = cls._nodes.get(parent_key) + if parent is not None: + workflow = parent[_WORKFLOW] + + if node[_PARENT] != parent_key: + cls._detach_locked(agent_key) + node[_PARENT] = parent_key + if parent_key: + cls._ensure_node(parent_key)[_CHILDREN].add(agent_key) + + cls._set_workflow_locked(agent_key, workflow) + + @classmethod + def unregister(cls, agent_uuid: str) -> List[str]: + """Remove *agent_uuid*, splicing its children onto its parent. + + Returns the UUIDs that were removed (currently just the node itself). + """ + agent_key = str(agent_uuid) + with cls._lock: + node = cls._nodes.pop(agent_key, None) + if node is None: + return [] + + parent_key = node[_PARENT] + parent = cls._nodes.get(parent_key) if parent_key else None + if parent is not None: + parent[_CHILDREN].discard(agent_key) + + for child in list(node[_CHILDREN]): + child_node = cls._nodes.get(child) + if child_node is None: + continue + child_node[_PARENT] = parent_key + if parent is not None: + parent[_CHILDREN].add(child) + + return [agent_key] + + @classmethod + def replace(cls, old_uuid: str, new_uuid: str) -> None: + """Move *old_uuid*'s position and children onto *new_uuid*.""" + old_key = str(old_uuid) + new_key = str(new_uuid) + if old_key == new_key: + return + + with cls._lock: + old_node = cls._nodes.pop(old_key, None) + if old_node is None: + cls.register(new_key) + return + + cls._detach_locked(new_key) + new_node = cls._ensure_node(new_key) + new_node[_NAME] = old_node[_NAME] + new_node[_PARENT] = old_node[_PARENT] + + parent_key = old_node[_PARENT] + if parent_key: + parent = cls._nodes.get(parent_key) + if parent is not None: + parent[_CHILDREN].discard(old_key) + parent[_CHILDREN].add(new_key) + + for child in list(old_node[_CHILDREN]): + child_node = cls._nodes.get(child) + if child_node is not None: + child_node[_PARENT] = new_key + new_node[_CHILDREN].add(child) + + cls._set_workflow_locked(new_key, old_node[_WORKFLOW]) + + @classmethod + def clear(cls) -> None: + """Drop every node (used by tests and full session teardown).""" + with cls._lock: + cls._nodes.clear() + + # ------------------------------------------------------------------ # + # Lookups + # ------------------------------------------------------------------ # + + @classmethod + def get_parent(cls, agent_uuid: str) -> Optional[str]: + """Return the parent UUID of *agent_uuid*, or None if it is a root.""" + with cls._lock: + node = cls._nodes.get(str(agent_uuid)) + return node[_PARENT] if node is not None else None + + @classmethod + def get_children(cls, agent_uuid: str) -> List[str]: + """Return the direct children of *agent_uuid*.""" + with cls._lock: + node = cls._nodes.get(str(agent_uuid)) + return list(node[_CHILDREN]) if node is not None else [] + + @classmethod + def get_descendants(cls, agent_uuid: str) -> List[str]: + """Return every descendant of *agent_uuid*, excluding itself.""" + with cls._lock: + return cls._descendants_locked(str(agent_uuid), set()) + + @classmethod + def get_subtree(cls, agent_uuid: str) -> List[str]: + """Return *agent_uuid* and all of its descendants.""" + with cls._lock: + agent_key = str(agent_uuid) + if agent_key not in cls._nodes: + return [] + return [agent_key] + cls._descendants_locked(agent_key, {agent_key}) + + @classmethod + def get_workflow_root(cls, agent_uuid: str) -> Optional[str]: + """Return the UUID of the workflow tree containing *agent_uuid*. + + Returns None for agents outside any workflow tree. + """ + with cls._lock: + node = cls._nodes.get(str(agent_uuid)) + return node[_WORKFLOW] if node is not None else None + + @classmethod + def is_workflow_agent(cls, agent_uuid: str) -> bool: + """Return whether *agent_uuid* is itself a workflow (``wf:``) agent.""" + with cls._lock: + node = cls._nodes.get(str(agent_uuid)) + return node is not None and cls._is_workflow_name(node[_NAME]) + + @classmethod + def get_visible_uuids(cls, observer_uuid: str) -> Set[str]: + """Return the UUIDs that *observer_uuid* is allowed to see. + + Agents outside a workflow domain see every non-workflow agent; agents + inside one see only the members of that same workflow tree. + """ + observer_key = str(observer_uuid) + with cls._lock: + node = cls._nodes.get(observer_key) + workflow = node[_WORKFLOW] if node is not None else None + + if workflow is not None: + visible = { + key for key, candidate in cls._nodes.items() if candidate[_WORKFLOW] == workflow + } + if workflow in cls._nodes: + visible.add(workflow) + return visible + + return {key for key, candidate in cls._nodes.items() if candidate[_WORKFLOW] is None} + + @classmethod + def is_visible(cls, observer_uuid: str, target_uuid: str) -> bool: + """Return whether *observer_uuid* may see *target_uuid*.""" + return str(target_uuid) in cls.get_visible_uuids(observer_uuid) + + # ------------------------------------------------------------------ # + # Internal helpers + # ------------------------------------------------------------------ # + + @classmethod + def _ensure_node(cls, agent_key: str) -> Dict[str, Any]: + node = cls._nodes.get(agent_key) + if node is None: + node = {_PARENT: None, _CHILDREN: set(), _NAME: None, _WORKFLOW: None} + cls._nodes[agent_key] = node + return node + + @classmethod + def _detach_locked(cls, agent_key: str) -> None: + node = cls._nodes.get(agent_key) + if node is None: + return + + parent_key = node[_PARENT] + if not parent_key: + return + + parent = cls._nodes.get(parent_key) + if parent is not None: + parent[_CHILDREN].discard(agent_key) + node[_PARENT] = None + + @classmethod + def _set_workflow_locked(cls, agent_key: str, workflow: Optional[str]) -> None: + node = cls._nodes.get(agent_key) + if node is None: + return + + if cls._is_workflow_name(node[_NAME]): + workflow = agent_key + + node[_WORKFLOW] = workflow + for child in list(node[_CHILDREN]): + cls._set_workflow_locked(child, workflow) + + @classmethod + def _descendants_locked(cls, agent_key: str, seen: Set[str]) -> List[str]: + node = cls._nodes.get(agent_key) + if node is None: + return [] + + result = [] + for child in list(node[_CHILDREN]): + if child in seen: + continue + seen.add(child) + result.append(child) + result.extend(cls._descendants_locked(child, seen)) + + return result + + @staticmethod + def _is_workflow_name(name: object) -> bool: + return isinstance(name, str) and name.startswith("wf:") diff --git a/cecli/tools/broadcast.py b/cecli/tools/broadcast.py index e886af5a299..fdc513cdbac 100644 --- a/cecli/tools/broadcast.py +++ b/cecli/tools/broadcast.py @@ -142,18 +142,22 @@ def format_output(cls, coder, mcp_server, tool_response): 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. + When ``targets`` is empty, returns every active sub-agent visible to + the originator. When ``targets`` is provided, each entry is resolved by + UUID, UUID prefix, or agent name (including ``primary`` when visible); + unknown or invisible IDs produce an error. """ from cecli.helpers.agents.service import SubAgentInfo, SubAgentStatus + from cecli.helpers.agents.tree import AgentTree + + visible_uuids = AgentTree.get_visible_uuids(originator_uuid) if not targets: infos = [ info for info in service.sub_agents.values() if str(info.coder.uuid) != originator_uuid + and str(info.coder.uuid) in visible_uuids and info.status not in (SubAgentStatus.ERROR,) ] return infos, [] @@ -161,7 +165,7 @@ def _collect_targets(cls, service, targets, originator_uuid): infos = [] errors = [] for target in targets: - info = cls._resolve_target(service, target) + info = cls._resolve_target(service, target, visible_uuids) if info is None: errors.append(f"Unknown sub-agent target '{target}'.") continue @@ -172,31 +176,42 @@ def _collect_targets(cls, service, targets, originator_uuid): return infos, errors @staticmethod - def _resolve_target(service, target): + def _resolve_target(service, target, visible_uuids=None): """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. + so sub-agents can broadcast a message back to their parent. When + *visible_uuids* is provided, candidates outside that set are ignored. """ target = str(target).strip() if not target: return None + def _visible(candidate): + if visible_uuids is None: + return True + return str(candidate.coder.uuid) in visible_uuids + info = service.sub_agents.get(target) - if info is not None: + if info is not None and _visible(info): return info for uuid, candidate in service.sub_agents.items(): - if uuid.startswith(target): + if uuid.startswith(target) and _visible(candidate): return candidate for candidate in service.sub_agents.values(): - if candidate.name == target: + if candidate.name == target and _visible(candidate): 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): + primary_visible = visible_uuids is None or primary_uuid in visible_uuids + if ( + primary_coder is not None + and primary_visible + and (target == "primary" or target == primary_uuid) + ): return primary_coder return None diff --git a/cecli/tools/delegate.py b/cecli/tools/delegate.py index cfe734f94d1..e5650f1b8f8 100644 --- a/cecli/tools/delegate.py +++ b/cecli/tools/delegate.py @@ -91,6 +91,14 @@ async def execute(cls, coder, **kwargs): if "prompt" not in d or not d["prompt"]: response.append_error(f"delegations[{i}] is missing a 'prompt'.") return response + # Workflow agents run in isolated trees and are launched by the + # workflow runner, never delegated to directly. + if str(d["name"]).startswith("wf:"): + response.append_error( + f"delegations[{i}] targets a workflow agent; " + "run workflows with the /workflows command instead." + ) + return response from cecli.helpers.agents.service import AgentService diff --git a/cecli/tui/widgets/floating_panels.py b/cecli/tui/widgets/floating_panels.py index fedad000077..0d11c37b556 100644 --- a/cecli/tui/widgets/floating_panels.py +++ b/cecli/tui/widgets/floating_panels.py @@ -335,8 +335,18 @@ def _collect_agents(self): from cecli.helpers.agents.service import AgentService service = AgentService.get_instance(primary) - registered = set(AgentService.get_registry().keys()) - live = {info.name for info in service.sub_agents.values() if info is not None} + # Workflow agents run in isolated trees and are not spawnable from + # this panel. + registered = { + name + for name in AgentService.get_registry().keys() + if not str(name).startswith("wf:") + } + live = { + info.name + for info in service.sub_agents.values() + if info is not None and not str(info.name).startswith("wf:") + } active_name = None foreground_uuid = getattr(service, "foreground_uuid", None) if foreground_uuid: diff --git a/tests/commands/test_agent_tree.py b/tests/commands/test_agent_tree.py new file mode 100644 index 00000000000..ad29e029925 --- /dev/null +++ b/tests/commands/test_agent_tree.py @@ -0,0 +1,83 @@ +"""Tests for the /agent-tree command.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from cecli.commands.agent_tree import AgentTreeCommand +from cecli.helpers.agents.service import AgentService, SubAgentStatus +from cecli.helpers.agents.tree import AgentTree + + +@pytest.fixture(autouse=True) +def clean_tree(): + """Start and end every test with an empty shared tree.""" + AgentTree.clear() + yield + AgentTree.clear() + + +def _info(name, coder, parent_uuid): + return SimpleNamespace( + name=name, + coder=coder, + parent_uuid=parent_uuid, + status=SubAgentStatus.RUNNING, + error=None, + ) + + +@pytest.mark.asyncio +async def test_agent_tree_reads_shared_tree_and_scopes_to_primary_subtree(): + """The command renders the primary's AgentTree subtree and nothing else.""" + primary = SimpleNamespace(uuid="primary", parent_uuid="") + worker = SimpleNamespace(uuid="worker", parent_uuid="primary") + other_worker = SimpleNamespace(uuid="other-worker", parent_uuid="other-primary") + + service = SimpleNamespace( + coder=primary, + sub_agents={ + "worker": _info("worker", worker, "primary"), + "other-worker": _info("other-worker", other_worker, "other-primary"), + }, + ) + + AgentTree.register("primary", name="primary") + AgentTree.register("worker", parent_uuid="primary", name="worker") + AgentTree.register("other-primary", name="primary") + AgentTree.register("other-worker", parent_uuid="other-primary", name="worker") + + io = MagicMock() + with patch.object(AgentService, "get_instance", return_value=service): + await AgentTreeCommand.execute(io, primary, "") + + output = "\n".join(call.args[0] for call in io.tool_output.call_args_list) + + assert "primary" in output + assert "worker" in output + assert "other-worker" not in output + + +@pytest.mark.asyncio +async def test_agent_tree_uses_tree_structure_not_coder_parent_uuid(): + """The rendered hierarchy follows AgentTree parent links.""" + primary = SimpleNamespace(uuid="primary", parent_uuid="") + worker = SimpleNamespace(uuid="worker", parent_uuid="") + + service = SimpleNamespace( + coder=primary, + sub_agents={"worker": _info("worker", worker, "")}, + ) + + AgentTree.register("primary", name="primary") + AgentTree.register("worker", parent_uuid="primary", name="worker") + + io = MagicMock() + with patch.object(AgentService, "get_instance", return_value=service): + await AgentTreeCommand.execute(io, primary, "") + + output = "\n".join(call.args[0] for call in io.tool_output.call_args_list) + + assert "└── primary" in output + assert "└── worker" in output diff --git a/tests/subagents/test_tree.py b/tests/subagents/test_tree.py new file mode 100644 index 00000000000..36e601244ef --- /dev/null +++ b/tests/subagents/test_tree.py @@ -0,0 +1,112 @@ +"""Tests for cecli/helpers/agents/tree.py — agent visibility domains.""" + +import pytest + +from cecli.helpers.agents.tree import AgentTree + + +@pytest.fixture(autouse=True) +def clean_tree(): + """Start and end every test with an empty tree.""" + AgentTree.clear() + yield + AgentTree.clear() + + +@pytest.fixture +def tree(): + """A primary/worker/workflow hierarchy. + + primary + ├── worker + │ └── grandchild + └── wf:demo + └── wf-child + """ + AgentTree.register("primary", name="primary") + AgentTree.register("worker", parent_uuid="primary", name="worker") + AgentTree.register("grandchild", parent_uuid="worker", name="worker") + AgentTree.register("wf:demo", parent_uuid="primary", name="wf:demo") + AgentTree.register("wf-child", parent_uuid="wf:demo", name="worker") + return AgentTree + + +class TestVisibility: + """Workflow trees are isolated from the rest of the agent hierarchy.""" + + def test_primary_sees_only_non_workflow_agents(self, tree): + visible = tree.get_visible_uuids("primary") + + assert visible == {"primary", "worker", "grandchild"} + assert not tree.is_visible("primary", "wf:demo") + assert not tree.is_visible("primary", "wf-child") + + def test_workflow_root_keeps_primary_as_structural_parent(self, tree): + """Isolation is visibility-only; the parent link is still the primary.""" + assert tree.get_parent("wf:demo") == "primary" + assert "wf:demo" in tree.get_children("primary") + assert not tree.is_visible("primary", "wf:demo") + + def test_regular_sub_agent_shares_primary_domain(self, tree): + visible = tree.get_visible_uuids("worker") + + assert visible == {"primary", "worker", "grandchild"} + assert not tree.is_visible("worker", "wf:demo") + + def test_workflow_root_sees_own_tree_only(self, tree): + visible = tree.get_visible_uuids("wf:demo") + + assert visible == {"wf:demo", "wf-child"} + assert not tree.is_visible("wf:demo", "primary") + assert not tree.is_visible("wf:demo", "worker") + + def test_workflow_descendant_sees_same_tree(self, tree): + visible = tree.get_visible_uuids("wf-child") + + assert visible == {"wf:demo", "wf-child"} + assert not tree.is_visible("wf-child", "primary") + + def test_nested_workflow_is_a_separate_domain(self): + AgentTree.register("primary", name="primary") + AgentTree.register("wf:outer", parent_uuid="primary", name="wf:outer") + AgentTree.register("outer-child", parent_uuid="wf:outer", name="worker") + AgentTree.register("wf:inner", parent_uuid="outer-child", name="wf:inner") + AgentTree.register("inner-child", parent_uuid="wf:inner", name="worker") + + assert AgentTree.get_visible_uuids("wf:outer") == {"wf:outer", "outer-child"} + assert AgentTree.get_visible_uuids("outer-child") == {"wf:outer", "outer-child"} + assert AgentTree.get_visible_uuids("wf:inner") == {"wf:inner", "inner-child"} + + +class TestMutations: + """Registration, removal and replacement maintain the hierarchy.""" + + def test_children_and_descendants(self, tree): + assert set(tree.get_children("primary")) == {"worker", "wf:demo"} + assert set(tree.get_descendants("primary")) == { + "worker", + "grandchild", + "wf:demo", + "wf-child", + } + + def test_unregister_splices_children_onto_parent(self, tree): + tree.unregister("wf:demo") + + assert tree.get_parent("wf-child") == "primary" + assert tree.get_workflow_root("wf-child") == "wf:demo" + assert tree.get_visible_uuids("wf-child") == {"wf-child"} + + def test_replace_moves_children_to_new_uuid(self, tree): + tree.replace("worker", "worker-2") + + assert tree.get_parent("worker-2") == "primary" + assert tree.get_parent("grandchild") == "worker-2" + assert tree.get_parent("worker") is None + assert tree.get_visible_uuids("worker-2") == {"primary", "worker-2", "grandchild"} + + def test_unknown_uuid_is_tolerated(self, tree): + assert tree.get_parent("missing") is None + assert tree.get_children("missing") == [] + assert tree.get_workflow_root("missing") is None + assert tree.get_visible_uuids("missing") == {"primary", "worker", "grandchild"} diff --git a/tests/tui/test_floating_panels.py b/tests/tui/test_floating_panels.py index 983197229b3..33ade48386a 100644 --- a/tests/tui/test_floating_panels.py +++ b/tests/tui/test_floating_panels.py @@ -203,7 +203,10 @@ async def _run_panel_checks(): def test_floating_panels_layout_scroll_and_toggle(): agent_service = SimpleNamespace( - sub_agents={"u1": SimpleNamespace(name="worker")}, + sub_agents={ + "u1": SimpleNamespace(name="worker"), + "u2": SimpleNamespace(name="wf:demo"), + }, foreground_uuid="u1", foreground_coder=None, ) @@ -214,7 +217,7 @@ def test_floating_panels_layout_scroll_and_toggle(): ), patch( "cecli.helpers.agents.service.AgentService.get_registry", - return_value={"tester": None, "worker": None}, + return_value={"tester": None, "worker": None, "wf:demo": None}, ), ): asyncio.run(_run_panel_checks()) From fcfaf9abc37f7502af10ca5eb24a0011534a4afd Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Thu, 29 Oct 2026 00:03:36 -0400 Subject: [PATCH 02/13] Rename Tools: Command to Shell, ResourceManager to ManageResources, Create to ManageExtensions --- cecli/coders/agent_coder.py | 14 ++-- cecli/helpers/agents/defaults/creator.md | 10 +-- cecli/helpers/agents/overrides.py | 2 +- cecli/helpers/orchestration/agent_proxy.py | 2 +- cecli/helpers/orchestration/safe_methods.py | 4 +- cecli/helpers/server/acp/agent/updates.py | 3 +- cecli/prompts/agent.yml | 2 +- cecli/prompts/subagent.yml | 2 +- cecli/tools/__init__.py | 12 ++-- .../tools/{create.py => manage_extensions.py} | 15 +++-- ...esource_manager.py => manage_resources.py} | 56 +++------------- cecli/tools/read_file.py | 2 +- cecli/tools/{command.py => shell.py} | 16 ++--- cecli/tools/utils/helpers.py | 4 +- cecli/website/docs/config/agent-mode.md | 20 +++--- cecli/website/docs/config/creator.md | 16 ++--- .../docs/config/custom-system-prompts.md | 2 +- cecli/website/docs/config/security.md | 2 +- tests/basic/test_background_commands.py | 8 +-- tests/basic/test_create_apply_to.py | 4 +- tests/basic/test_create_tool.py | 10 +-- .../integration/test_agent_mcp_management.py | 10 +-- tests/integration/test_mcp_management.py | 10 +-- tests/mcp/test_manager_retry.py | 12 ++-- tests/tools/test_create_format_output.py | 12 ++-- ...ing.py => test_manage_resources_paging.py} | 41 ++++++------ tests/tools/test_registry.py | 64 +++++++++---------- tests/tools/test_remove_mcp_tool.py | 20 +++--- ..._output.py => test_shell_format_output.py} | 12 ++-- ...paging.py => test_shell_timeout_paging.py} | 10 +-- tests/tools/test_tools_load_mcp_tool.py | 24 +++---- tests/unit/test_load_mcp.py | 24 +++---- tests/unit/test_remove_mcp.py | 20 +++--- tests/unit/test_unit_load_mcp_tool.py | 10 +-- tests/unit/test_unit_remove_mcp_tool.py | 20 +++--- 35 files changed, 228 insertions(+), 267 deletions(-) rename cecli/tools/{create.py => manage_extensions.py} (96%) rename cecli/tools/{resource_manager.py => manage_resources.py} (92%) rename cecli/tools/{command.py => shell.py} (99%) rename tests/tools/{test_resource_manager_paging.py => test_manage_resources_paging.py} (89%) rename tests/tools/{test_command_format_output.py => test_shell_format_output.py} (92%) rename tests/tools/{test_command_timeout_paging.py => test_shell_timeout_paging.py} (96%) diff --git a/cecli/coders/agent_coder.py b/cecli/coders/agent_coder.py index 25532f3fc9b..b9622fd856a 100644 --- a/cecli/coders/agent_coder.py +++ b/cecli/coders/agent_coder.py @@ -63,7 +63,7 @@ def __init__(self, *args, **kwargs): self.tool_similarity_threshold = 0.95 self.max_tool_vector_history = 20 self.read_tools = { - "command", + "shell", "explorecode", "readfile", "grep", @@ -71,7 +71,7 @@ def __init__(self, *args, **kwargs): "searchfacts", } self.write_tools = { - "command", + "shell", "editfile", "undochange", "replacefacts", @@ -234,7 +234,7 @@ def _get_agent_config(self, config=None): config, ["tools_excludelist", "tools_blacklist"], [ - "create", + "manageextensions", "gitbranch", "gitdiff", "gitlog", @@ -783,7 +783,7 @@ def get_context_summary(self): result += f" ({percentage:.1f}% of limit)" if percentage > 80: result += "\n\n⚠ **Context is getting full!**\n" - result += "- Remove non-essential files, skills and tool servers via the `ResourceManager` tool.\n" + result += "- Remove non-essential files, skills and tool servers via the `ManageResources` tool.\n" result += "- Keep only essential files, skills, and MCP servers in context for best performance" result += "\n" if not hasattr(self, "context_blocks_cache"): @@ -1519,7 +1519,7 @@ async def check_for_file_mentions(self, content): Override parent's method to disable implicit file mention handling in agent mode. Files should only be added via explicit tool commands - (`ResourceManager`). + (`ManageResources`). """ pass @@ -1964,7 +1964,7 @@ def get_background_command_output(self): Emits a roster of active commands (keeping command keys in context) plus any new incremental output. Output that has been paged to disk is - advertised by command key and read on demand through ``ResourceManager`` + advertised by command key and read on demand through ``ManageResources`` paging. """ command_info = BackgroundCommandManager.list_background_commands() @@ -1997,7 +1997,7 @@ def get_background_command_output(self): output += f"\nNew output ({command_key}):\n{cmd_output}\n" if paged_keys: - output += "\nPaged output is available via `ResourceManager` (up to 3 pages):\n" + output += "\nPaged output is available via `ManageResources` (up to 3 pages):\n" for command_key in paged_keys: output += f'{{"paging": [{{"target": "{command_key}", "page": 1}}]}}\n' diff --git a/cecli/helpers/agents/defaults/creator.md b/cecli/helpers/agents/defaults/creator.md index 269e9fa8dba..a4a655ebf0e 100644 --- a/cecli/helpers/agents/defaults/creator.md +++ b/cecli/helpers/agents/defaults/creator.md @@ -39,7 +39,7 @@ You create and maintain cecli extensions and configuration: - **custom tools** (`Tool` classes loaded from tools directories) - **custom commands** (`CustomCommand` classes loaded via `custom.command-paths`) - **workflows** (YAML workflow definitions) -- **project/global configuration** (persisted to `creator.yml` through the `Create` tool) +- **project/global configuration** (persisted to `creator.yml` through the `ManageExtensions` tool) Do not implement general application features, fix unrelated bugs, or debug the user's project. If a request is outside this scope, explain that briefly and @@ -64,11 +64,11 @@ Common locations: - Workflows: `.cecli/workflows/*.yml`, `~/.cecli/workflows/*.yml` - Custom commands: paths listed under `custom.command-paths` -These are read/reference only — the `Create` tool owns all writes. +These are read/reference only — the `ManageExtensions` tool owns all writes. -## Persistence is the Create tool's job +## Persistence is the ManageExtensions tool's job -All extension and config writes go through `Create` — never edit `creator.yml` +All extension and config writes go through `ManageExtensions` — never edit `creator.yml` or user config files (`.cecli.conf.yml`, `conf.yml`) yourself. Each call writes the backing files, records the needed `creator.yml` entry (global `~/.cecli/creator.yml` or project-local `.cecli/creator.yml`, deep-merged with @@ -91,7 +91,7 @@ non-extension settings use `type: config` with a dotted key path as `name`, and 1. Clarify the goal with `Broadcast` when the request depends on prior context. 2. Consult the documentation before choosing a format or config key. 3. Inspect existing files and config before changing anything so you preserve - user work; use `Create(action: "list")` to see what is already persisted. + user work; use `ManageExtensions(action: "list")` to see what is already persisted. 4. Make the smallest change that fully implements the request. 5. Validate: run `python -m py_compile` for Python files, parse YAML/JSON, and re-read edited files to confirm the result. diff --git a/cecli/helpers/agents/overrides.py b/cecli/helpers/agents/overrides.py index a04932a617a..48973cb044b 100644 --- a/cecli/helpers/agents/overrides.py +++ b/cecli/helpers/agents/overrides.py @@ -80,7 +80,7 @@ def load_agent_overrides(primary_root: Optional[str] = None) -> Dict[str, Any]: def refresh_agent_overrides(primary_root: Optional[str] = None) -> Dict[str, Any]: """Re-read the ``creator.yml`` files and refresh the spawn-time index. - Both startup (``main_async``) and the Create tool use this single reader + Both startup (``main_async``) and the ManageExtensions tool use this single reader path: it re-reads the global and local ``creator.yml`` files, updates ``AgentService``'s override index, and re-registers/removes dynamic ``creator:`` definitions. Call it after any mutation of a diff --git a/cecli/helpers/orchestration/agent_proxy.py b/cecli/helpers/orchestration/agent_proxy.py index c753d075d43..a016d8d4ff9 100644 --- a/cecli/helpers/orchestration/agent_proxy.py +++ b/cecli/helpers/orchestration/agent_proxy.py @@ -566,7 +566,7 @@ class _HelpfulBuiltins(dict): """Custom __builtins__ dict that provides helpful hints for missing functions.""" _HINTS: dict[str, str] = { - "open": "Filesystem access is not available. Use the Command tool instead.", + "open": "Filesystem access is not available. Use the Shell tool instead.", "eval": "eval() is disabled for security.", "exec": "exec() is disabled for security.", "__import__": "Imports are disabled. Use only the primitives provided.", diff --git a/cecli/helpers/orchestration/safe_methods.py b/cecli/helpers/orchestration/safe_methods.py index ef54efb96bb..3755999baff 100644 --- a/cecli/helpers/orchestration/safe_methods.py +++ b/cecli/helpers/orchestration/safe_methods.py @@ -50,7 +50,7 @@ def __getattr__(self, name: str): raise SecurityError( f"Cannot call '{name}' on Path objects in the sandbox. " - f"Filesystem I/O is restricted to the Command tool." + f"Filesystem I/O is restricted to the Shell tool." ) import pathlib as _pathlib @@ -1045,7 +1045,7 @@ class _HelpfulBuiltins(dict): """Custom __builtins__ dict that provides helpful hints for missing functions.""" _HINTS: dict[str, str] = { - "open": "Filesystem access is not available. Use the Command tool instead.", + "open": "Filesystem access is not available. Use the Shell tool instead.", "eval": "eval() is disabled for security.", "exec": "exec() is disabled for security.", "__import__": "Imports are disabled. Use only the primitives provided.", diff --git a/cecli/helpers/server/acp/agent/updates.py b/cecli/helpers/server/acp/agent/updates.py index 5e9cd35a894..7db2ad69c04 100644 --- a/cecli/helpers/server/acp/agent/updates.py +++ b/cecli/helpers/server/acp/agent/updates.py @@ -40,11 +40,12 @@ "explorecode": "search", "searchfacts": "search", "editfile": "edit", + "manageextensions": "edit", "undochange": "edit", "replacefacts": "edit", # Not "execute": Zed treats execute calls as live terminals and hides its # progress spinner while one runs, but cecli runs commands itself - "command": "other", + "shell": "other", } # Argument keys worth showing in a tool call's title, in priority order diff --git a/cecli/prompts/agent.yml b/cecli/prompts/agent.yml index ec3a44ffaa6..900bed62f2e 100644 --- a/cecli/prompts/agent.yml +++ b/cecli/prompts/agent.yml @@ -57,7 +57,7 @@ main_system: | - **Scope**: No unrequested refactors. Avoid full-file rewrites. Only modify what you are asked to. - **Discover**: Use `ExploreCode` for symbols, `Grep` for text, and `ReadFile` to inspect matches. - **Plan**: Outline multi-step work with `UpdateTodoList`. - - **Execute**: Make read-only files editable with `ResourceManager` before editing, then review the diff and fix lint errors immediately. + - **Execute**: Make read-only files editable with `ManageResources` before editing, then review the diff and fix lint errors immediately. - **Recover**: Repair a bad edit in place; use `UndoChange` only when the corruption is beyond repair. - **Outputs**: Tool calls trigger turns. Yield when finished with the final user summary. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. diff --git a/cecli/prompts/subagent.yml b/cecli/prompts/subagent.yml index 0348af624a3..b43afbf8fea 100644 --- a/cecli/prompts/subagent.yml +++ b/cecli/prompts/subagent.yml @@ -42,7 +42,7 @@ main_system: | - **Scope**: No unrequested refactors. Avoid full-file rewrites. Only modify what you are asked to. - **Discover**: Use `ExploreCode` for symbols, `Grep` for text, and `ReadFile` to inspect matches. - **Plan**: Outline multi-step work with `UpdateTodoList`. - - **Execute**: Make read-only files editable with `ResourceManager` before editing, then review the diff and fix lint errors immediately. + - **Execute**: Make read-only files editable with `ManageResources` before editing, then review the diff and fix lint errors immediately. - **Recover**: Repair a bad edit in place; use `UndoChange` only when the corruption is beyond repair. - **Outputs**: Tool calls trigger turns. Yield when finished with the final user summary. - **Sandbox**: Perform all verification and temp logic in `.cecli/temp`. diff --git a/cecli/tools/__init__.py b/cecli/tools/__init__.py index aabb5036024..10299be8aac 100644 --- a/cecli/tools/__init__.py +++ b/cecli/tools/__init__.py @@ -4,8 +4,6 @@ from . import ( _yield, broadcast, - command, - create, delegate, edit_file, explore_code, @@ -16,20 +14,21 @@ git_show, git_status, grep, + manage_extensions, + manage_resources, orchestrate, read_file, replace_facts, - resource_manager, search_docs, search_facts, + shell, undo_change, update_todo_list, ) # List of all available tool modules for dynamic discovery TOOL_MODULES = [ - command, - create, + shell, delegate, edit_file, explore_code, @@ -42,10 +41,11 @@ git_show, git_status, grep, + manage_extensions, + manage_resources, orchestrate, read_file, replace_facts, - resource_manager, search_docs, search_facts, undo_change, diff --git a/cecli/tools/create.py b/cecli/tools/manage_extensions.py similarity index 96% rename from cecli/tools/create.py rename to cecli/tools/manage_extensions.py index a5a7f745b7e..e330c205ba0 100644 --- a/cecli/tools/create.py +++ b/cecli/tools/manage_extensions.py @@ -1,4 +1,4 @@ -"""Create tool - persists cecli extensions into the session's creator.yml files.""" +"""ManageExtensions tool - persists cecli extensions into the session's creator.yml files.""" import json import shutil @@ -34,15 +34,16 @@ class Tool(BaseTool): - NORM_NAME = "create" + NORM_NAME = "manageextensions" RESULT_TYPE = "list" + TRACK_INVOCATIONS = False SCHEMA = { "type": "function", "function": { - "name": "Create", + "name": "ManageExtensions", "description": ( - "Persist a cecli extension or config change into creator.yml so it " - "survives sessions. One action per call: add/update entries of a given " + "Manage persisted cecli extensions and config in creator.yml so they " + "survive sessions. One action per call: add/update entries of a given " "type into the global (~/.cecli/creator.yml) or local " "(/.cecli/creator.yml) file, remove entries, or list current " "contents. Scoped to the creator sub-agent." @@ -149,7 +150,7 @@ async def execute( """ if not is_creator_coder(coder): raise ToolError( - "The Create tool is scoped to the creator sub-agent. Delegate the " + "The ManageExtensions tool is scoped to the creator sub-agent. Delegate the " "request to the creator agent instead." ) @@ -224,7 +225,7 @@ async def execute( @classmethod def format_output(cls, coder, mcp_server, tool_response): - """Format output for Create tool - preview the requested change.""" + """Format output for ManageExtensions tool - preview the requested change.""" color_start, color_end = color_markers(coder) tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) diff --git a/cecli/tools/resource_manager.py b/cecli/tools/manage_resources.py similarity index 92% rename from cecli/tools/resource_manager.py rename to cecli/tools/manage_resources.py index 84fbd68e2c6..e9e2ffbd8e6 100644 --- a/cecli/tools/resource_manager.py +++ b/cecli/tools/manage_resources.py @@ -8,7 +8,6 @@ update_server_registration, ) from cecli.decoding import safe_open -from cecli.helpers.background_commands import BackgroundCommandManager from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ToolError, parse_arg_as_list from cecli.tools.utils.output import color_markers, tool_footer, tool_header @@ -17,17 +16,17 @@ class Tool(BaseTool): - NORM_NAME = "resourcemanager" + NORM_NAME = "manageresources" RESULT_TYPE = "list" SCHEMA = { "type": "function", "function": { - "name": "ResourceManager", + "name": "ManageResources", "description": ( - "Manage files, long running commands, skills, and MCP servers" - " in the chat context: add, read_only, create, remove files;" + "Manage files, skills, and MCP servers in the chat context:" + " add, read_only, create, remove files;" " view up to 3 pages of command output with paging;" - " stop background commands; load/remove skills and load/remove MCP servers." + " load/remove skills and load/remove MCP servers." ), "parameters": { "type": "object", @@ -52,11 +51,6 @@ class Tool(BaseTool): "items": {"type": "string"}, "description": "List of file paths to remove from context.", }, - "stop": { - "type": "array", - "items": {"type": "string"}, - "description": "List of command keys to stop background commands for.", - }, "load_skill": { "type": "array", "items": {"type": "string"}, @@ -102,7 +96,7 @@ class Tool(BaseTool): "target": { "type": "string", "description": ( - "Command key returned by Command (e.g., bg_1_1234), " + "Command key returned by Shell (e.g., bg_1_1234), " "matching ^bg_[0-9]+_[0-9]+$." ), }, @@ -127,7 +121,6 @@ async def execute( add=None, read_only=None, create=None, - stop=None, load_skill=None, remove_skill=None, load_mcp=None, @@ -150,8 +143,6 @@ async def execute( Files to add as read-only (not command output aliases). create: list[str] | None Files to create and make editable. - stop: list[str] | None - Command keys to stop background commands for. load_skill: list[str] | None Skill names to load. remove_skill: list[str] | None @@ -170,7 +161,6 @@ async def execute( editable_files = sorted(parse_arg_as_list(add), key=cls._natural_sort_key) view_files = sorted(parse_arg_as_list(read_only), key=cls._natural_sort_key) create_files = sorted(parse_arg_as_list(create), key=cls._natural_sort_key) - stop_keys = sorted(parse_arg_as_list(stop), key=cls._natural_sort_key) load_skill_names = sorted(parse_arg_as_list(load_skill), key=cls._natural_sort_key) remove_skill_names = sorted(parse_arg_as_list(remove_skill), key=cls._natural_sort_key) load_mcp_servers = sorted(parse_arg_as_list(load_mcp), key=cls._natural_sort_key) @@ -192,7 +182,6 @@ async def execute( and not editable_files and not view_files and not create_files - and not stop_keys and not load_skill_names and not remove_skill_names and not load_mcp_servers @@ -201,7 +190,7 @@ async def execute( and paging is None ): raise ToolError( - "You must specify at least one of: remove, add, read_only, create, stop, " + "You must specify at least one of: remove, add, read_only, create, " "load_skill, remove_skill, load_mcp, remove_mcp, actions, or paging" ) @@ -270,8 +259,6 @@ async def execute( response.append_result(cls._create(coder, f)) else: response.append_result(cls._editable(coder, f)) - for key in stop_keys: - response.append_result(cls._stop_command(coder, key)) for skill_name in load_skill_names: response.append_result(cls._load_skill(coder, skill_name)) for skill_name in remove_skill_names: @@ -298,7 +285,7 @@ async def execute( @classmethod def format_output(cls, coder, mcp_server, tool_response): - """Format output for ResourceManager tool.""" + """Format output for ManageResources tool.""" color_start, color_end = color_markers(coder) # Output header @@ -318,7 +305,6 @@ def format_output(cls, coder, mcp_server, tool_response): "remove": "remove", "view": "view", "editable": "editable", - "stop": "stop", "load_skill": "load_skill", "remove_skill": "remove_skill", "load_mcp": "load_mcp", @@ -375,32 +361,6 @@ def _remove(cls, coder, file_path): coder.io.tool_error(f"Error removing file '{file_path}': {str(e)}") return f"Error removing {file_path}: {e}" - @classmethod - def _stop_command(cls, coder, command_key): - """Stop a background command by its command key.""" - try: - success, output, exit_code = BackgroundCommandManager.stop_background_command( - command_key - ) - if success: - coder.io.tool_output( - f"✗ Stopped background command '{command_key}'", type="tool-result" - ) - return ( - f"Background command stopped: {command_key}\n" - f"Exit code: {exit_code}\n" - f"Final output:\n{output}" - ) - else: - coder.io.tool_output( - f"⚠ Background command '{command_key}' not found or not running", - type="tool-result", - ) - return f"Command not found or not running: {command_key}" - except Exception as e: - coder.io.tool_error(f"Error stopping command '{command_key}': {str(e)}") - return f"Error stopping {command_key}: {e}" - @classmethod def _editable(cls, coder, file_path): """Make a file editable in the coder's context.""" diff --git a/cecli/tools/read_file.py b/cecli/tools/read_file.py index e7c67ebcbfe..f65aba80c12 100644 --- a/cecli/tools/read_file.py +++ b/cecli/tools/read_file.py @@ -250,7 +250,7 @@ def execute(cls, coder, read, **kwargs): "outline": "", "note": ( f"File {rel_path} is empty. Next: use EditFile with start_line @000 and" - " end_line @000 to write content, or ResourceManager to scaffold —" + " end_line @000 to write content, or ManageResources to scaffold —" " do not call ReadFile again on this empty file." ), } diff --git a/cecli/tools/command.py b/cecli/tools/shell.py similarity index 99% rename from cecli/tools/command.py rename to cecli/tools/shell.py index 1eea71819c4..fca3f48a54e 100644 --- a/cecli/tools/command.py +++ b/cecli/tools/shell.py @@ -59,13 +59,13 @@ class Tool(BaseTool): - NORM_NAME = "command" + NORM_NAME = "shell" TRACK_INVOCATIONS = False ALLOWED_SESSION_COMMANDS = {} SCHEMA = { "type": "function", "function": { - "name": "Command", + "name": "Shell", "description": "Execute a shell command or interact with background processes.", "parameters": { "type": "object", @@ -177,7 +177,7 @@ async def execute( Commands run with timeout from agent_config['command_timeout'] (default: 30 seconds), """ response = ToolResponse( - "command", + "shell", result_type=cls.RESULT_TYPE if hasattr(cls, "RESULT_TYPE") else "str", ) # Handle interactions with an existing background command @@ -295,7 +295,7 @@ async def _get_confirmation(cls, coder, command_string, background): prompt, explicit_yes_required=True, allow_never=True, - group_response="Command Tool", + group_response="Shell Tool", ) if confirmed: @@ -597,7 +597,7 @@ async def _execute_foreground(cls, coder, command_string): f"Output saved in {len(file_list)} pages.]\n" f"Command key: {fg_key}\n" f"Pages: 1-{len(file_list)}\n" - "Use `ResourceManager` to view up to 3 pages at a time:\n" + "Use `ManageResources` to view up to 3 pages at a time:\n" f'{{"paging": [{{"target": "{fg_key}", "page": 1}}]}}\n' "Change page or add entries to read other pages (maximum 3 entries). " "Do not use add, read_only, or standard CLI tools to view command output " @@ -764,7 +764,7 @@ async def _tail_background_command(cls, coder, command_key): f"Output so far: {info.get('total_chars', 0):,} chars", ] if pages: - lines.append(f"Paged output: pages 1-{pages}. Read with ResourceManager paging.") + lines.append(f"Paged output: pages 1-{pages}. Read with ManageResources paging.") lines.append(f'{{"paging": [{{"target": "{command_key}", "page": 1}}]}}') if output.strip(): @@ -787,7 +787,7 @@ def _pages_notice(command_key, page_count): f"[Output paged to disk: {page_count} page(s).]\n" f"Command key: {command_key}\n" f"Pages: 1-{page_count}\n" - "Use `ResourceManager` to view up to 3 pages at a time:\n" + "Use `ManageResources` to view up to 3 pages at a time:\n" f'{{"paging": [{{"target": "{command_key}", "page": 1}}]}}\n' "Change the page number to read other pages (maximum 3 entries). " "Do not use add, read_only, or standard CLI tools to view command output " @@ -796,7 +796,7 @@ def _pages_notice(command_key, page_count): @classmethod def format_output(cls, coder, mcp_server, tool_response): - """Format output for Command tool.""" + """Format output for Shell tool.""" color_start, color_end = color_markers(coder) # Output header diff --git a/cecli/tools/utils/helpers.py b/cecli/tools/utils/helpers.py index efca476cdfe..ba749024d17 100644 --- a/cecli/tools/utils/helpers.py +++ b/cecli/tools/utils/helpers.py @@ -92,12 +92,12 @@ def validate_file_for_edit(coder, file_path): if abs_path not in coder.abs_fnames: if abs_path in coder.abs_read_only_fnames: raise ToolError( - f"File '{file_path}' is read-only. Make editable with `ResourceManager` first." + f"File '{file_path}' is read-only. Make editable with `ManageResources` first." ) # else: # # File exists but is not in context at all # raise ToolError( - # f"File '{file_path}' not in context. Make editable with `ResourceManager` first." + # f"File '{file_path}' not in context. Make editable with `ManageResources` first." # ) # Reread content immediately before potential modification diff --git a/cecli/website/docs/config/agent-mode.md b/cecli/website/docs/config/agent-mode.md index cf490bb1602..26ea45c5b26 100644 --- a/cecli/website/docs/config/agent-mode.md +++ b/cecli/website/docs/config/agent-mode.md @@ -47,8 +47,8 @@ This loop continues automatically until the `Yield` tool is called, or the maxim Agent Mode uses a centralized local tool registry. The standard tools include: - **Discovery and inspection**: `ExploreCode`, `Grep`, and `ReadFile` -- **Context and editing**: `ResourceManager`, `EditFile`, `UndoChange`, and `UpdateTodoList` -- **Execution and orchestration**: `Command` and `Orchestrate` +- **Context and editing**: `ManageResources`, `EditFile`, `UndoChange`, and `UpdateTodoList` +- **Execution and orchestration**: `Shell` and `Orchestrate` - **Sub-agent coordination**: `Delegate` and `Yield` - **Optional memory tools**: `SearchFacts` and `ReplaceFacts` @@ -93,7 +93,7 @@ Tool Call: Grep Arguments: {"searches": [{"pattern": "function_name", "directory": "."}]} ``` -`ExploreCode` uses a codebase index when available. `ReadFile` returns content with virtual identifiers for precise follow-up edits. Files discovered through agent tools can be added as read-only context by using `ResourceManager` to change context membership. +`ExploreCode` uses a codebase index when available. `ReadFile` returns content with virtual identifiers for precise follow-up edits. Files discovered through agent tools can be added as read-only context by using `ManageResources` to change context membership. #### 2. Planning Phase @@ -106,17 +106,17 @@ Arguments: {"content": "## Task: Add new feature\n- [ ] Analyze existing code\n- #### 3. Execution Phase -Files are added to context and made editable with `ResourceManager`, then modifications are applied with `EditFile`: +Files are added to context and made editable with `ManageResources`, then modifications are applied with `EditFile`: ```text -Tool Call: ResourceManager +Tool Call: ManageResources Arguments: {"add": ["main.py"]} Tool Call: EditFile Arguments: {"edits": [{"file_path": "main.py", "operation": "replace", "start_line": "——def old_function():", "end_line": "—— return old_value", "text": "def new_function():\n return new_value"}]} ``` -`EditFile` accepts an `edits` array containing `replace` or `delete` operations. Its line targets come from the latest `ReadFile` result. `ResourceManager` can add files as editable or read-only and remove them from context. +`EditFile` accepts an `edits` array containing `replace` or `delete` operations. Its line targets come from the latest `ReadFile` result. `ManageResources` can add files as editable or read-only and remove them from context. #### 4. Verification Phase @@ -221,9 +221,9 @@ agent-config: #### Essential Tools -Only `Yield` is protected as an essential registry tool. Other tools, including `ResourceManager` and `ReadFile`, can be restricted by includelist/excludelist settings. +Only `Yield` is protected as an essential registry tool. Other tools, including `ManageResources` and `ReadFile`, can be restricted by includelist/excludelist settings. -- `ResourceManager` - Add, drop, and make files editable in context +- `ManageResources` - Add, drop, and make files editable in context - `ReadFile` - Read file contents with virtual content IDs - `Yield` - Complete the task and wait for active child agents @@ -333,8 +333,8 @@ agent: true # Agent Mode configuration agent-config: # Tool configuration - tools_includelist: ["resourcemanager", "readfile", "yield"] - tools_excludelist: ["command"] + tools_includelist: ["manageresources", "readfile", "yield"] + tools_excludelist: ["shell"] tools_paths: ["./custom-tools", "~/my-tools"] # Server configuration diff --git a/cecli/website/docs/config/creator.md b/cecli/website/docs/config/creator.md index 240a98670e6..87a3e0671d5 100644 --- a/cecli/website/docs/config/creator.md +++ b/cecli/website/docs/config/creator.md @@ -106,18 +106,18 @@ Backing files are left on disk by default so you can inspect them; say "delete t ## Safety Notes -- The creator never hand-edits `.cecli.conf.yml`, `conf.yml`, or `creator.yml`. All writes go through its Create tool, which merges rather than replaces existing content. +- The creator never hand-edits `.cecli.conf.yml`, `conf.yml`, or `creator.yml`. All writes go through its ManageExtensions tool, which merges rather than replaces existing content. - It inspects existing files before changing them, but it is designed to run unattended, so review its summary and use `list` when you want to confirm what was persisted. - Removing an entry prunes references but keeps backing files unless you ask to delete them. - Settings that only apply at startup (for example some model/provider options) may need `/hot-reload` or a restart; the creator tells you when that is the case. ## Advanced Reference -Everything below describes the underlying Create tool. You normally do not need it — ask the creator instead. +Everything below describes the underlying ManageExtensions tool. You normally do not need it — ask the creator instead. -### Create tool +### ManageExtensions tool -The Create tool is scoped to the creator sub-agent (`creator_mode: true`). The primary agent and other sub-agents cannot call it directly; delegate the request to the creator. One action runs per call. +The ManageExtensions tool is scoped to the creator sub-agent (`creator_mode: true`). The primary agent and other sub-agents cannot call it directly; delegate the request to the creator. One action runs per call. | Action | Behavior | |--------|----------| @@ -181,7 +181,7 @@ See [Hooks](hooks.html) for the hook payload schema. #### `skill` -Writes `/skills//SKILL.md` from `content`. If `content` is omitted and the file does not exist, Create scaffolds a minimal `SKILL.md` with `name` and `description` front matter for the author to fill in. The skill directory is recorded in `agent-config.skills_paths`; when a live coder already runs with a non-empty `skills_includelist`, the new name is added there as well. +Writes `/skills//SKILL.md` from `content`. If `content` is omitted and the file does not exist, ManageExtensions scaffolds a minimal `SKILL.md` with `name` and `description` front matter for the author to fill in. The skill directory is recorded in `agent-config.skills_paths`; when a live coder already runs with a non-empty `skills_includelist`, the new name is added there as well. ```yaml agent-config: @@ -195,7 +195,7 @@ After an `add`, the creator activates the skill for the targeted coders (or ever #### `custom_tool` -Writes `/tools/.py` from `content` and records the scope's tools directory in `agent-config.tools_paths`. `~/.cecli/tools` and `/.cecli/tools` are scanned by default, but recording the path keeps the directory explicit. If a live coder's `tools_excludelist` names the tool, Create notes that the tool stays hidden there. +Writes `/tools/.py` from `content` and records the scope's tools directory in `agent-config.tools_paths`. `~/.cecli/tools` and `/.cecli/tools` are scanned by default, but recording the path keeps the directory explicit. If a live coder's `tools_excludelist` names the tool, ManageExtensions notes that the tool stays hidden there. ```yaml agent-config: @@ -215,7 +215,7 @@ custom: #### `workflow` -Writes `/workflows/.yml` from `content` or from a dict `config` (a `name` is added when missing). Workflows are discovered by scanning the workflows directories, so Create records **no fragment** in `creator.yml`. If the workflow has `enabled: false`, Create warns that no `wf:` sub-agent will register. +Writes `/workflows/.yml` from `content` or from a dict `config` (a `name` is added when missing). Workflows are discovered by scanning the workflows directories, so ManageExtensions records **no fragment** in `creator.yml`. If the workflow has `enabled: false`, ManageExtensions warns that no `wf:` sub-agent will register. > **Important:** Workflows are never recorded as a top-level list of dicts in `creator.yml`. A `workflows:` list in that file would bypass the normal workflow file discovery and precedence rules. @@ -251,7 +251,7 @@ agents: - github ``` -`agents:` is read **only** from `creator.yml` (global then project-local); entries in `.cecli.conf.yml` or `.cecli/conf.yml` are ignored. Create refreshes the index after every successful change, so sub-agents spawned later in the same session pick up the override without a restart. +`agents:` is read **only** from `creator.yml` (global then project-local); entries in `.cecli.conf.yml` or `.cecli/conf.yml` are ignored. ManageExtensions refreshes the index after every successful change, so sub-agents spawned later in the same session pick up the override without a restart. **Names and patterns.** Targets are exact registry names or `fnmatch` patterns (`"*"`, `"wf:*"`, `"ws:*"`). When a sub-agent spawns, matching overrides merge most-specific-last: diff --git a/cecli/website/docs/config/custom-system-prompts.md b/cecli/website/docs/config/custom-system-prompts.md index d94f00dfdf2..76dd3fcffec 100644 --- a/cecli/website/docs/config/custom-system-prompts.md +++ b/cecli/website/docs/config/custom-system-prompts.md @@ -83,7 +83,7 @@ main_system: | ## Core Directives - **Role**: Act as an expert software engineer. - - **Act Proactively**: Autonomously use file discovery and context management tools (`ExploreCode`, `Grep`, `ReadFile`, `ResourceManager`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. + - **Act Proactively**: Autonomously use file discovery and context management tools (`ExploreCode`, `Grep`, `ReadFile`, `ManageResources`) to gather information and fulfill the user's request. Chain tool calls across multiple turns to continue exploration. - **Be Decisive**: Trust that your initial findings are valid. Refrain from asking the same question or searching for the same term in multiple similar ways. - **Be Concise**: Keep all responses brief and direct (1-3 sentences). Avoid preamble, postamble, and unnecessary explanations. Do not repeat yourself. - **Be Careful**: Break updates down into smaller, more manageable chunks. Focus on one thing at a time. diff --git a/cecli/website/docs/config/security.md b/cecli/website/docs/config/security.md index 53c148cf7de..7c51c6f4e0a 100644 --- a/cecli/website/docs/config/security.md +++ b/cecli/website/docs/config/security.md @@ -167,7 +167,7 @@ When using Agent Mode, security configuration works alongside other security fea ```yaml agent: true agent-config: - tools_excludelist: ["command"] + tools_excludelist: ["shell"] security-config: allowed-domains: - github.com diff --git a/tests/basic/test_background_commands.py b/tests/basic/test_background_commands.py index 1f7f079a274..cdf9abb7e82 100644 --- a/tests/basic/test_background_commands.py +++ b/tests/basic/test_background_commands.py @@ -294,7 +294,7 @@ def test_tail_background_command_reports_output_and_pages(monkeypatch): """The tail action reports status, page roster, and new output.""" import asyncio - from cecli.tools.command import Tool as CommandTool + from cecli.tools.shell import Tool as ShellTool monkeypatch.setattr( BackgroundCommandManager, @@ -312,7 +312,7 @@ def test_tail_background_command_reports_output_and_pages(monkeypatch): BackgroundCommandManager, "get_new_command_output", lambda key: "new line\n" ) - response = asyncio.run(CommandTool._tail_background_command(object(), "bg_1_1234")) + response = asyncio.run(ShellTool._tail_background_command(object(), "bg_1_1234")) content = response.to_dict()["result"][0]["content"] assert "bg_1_1234" in content @@ -325,11 +325,11 @@ def test_tail_background_command_reports_output_and_pages(monkeypatch): def test_tail_background_command_missing_key(monkeypatch): import asyncio - from cecli.tools.command import Tool as CommandTool + from cecli.tools.shell import Tool as ShellTool monkeypatch.setattr(BackgroundCommandManager, "list_background_commands", lambda: {}) - response = asyncio.run(CommandTool._tail_background_command(object(), "bg_9_9999")) + response = asyncio.run(ShellTool._tail_background_command(object(), "bg_9_9999")) assert response.to_dict()["errors"] diff --git a/tests/basic/test_create_apply_to.py b/tests/basic/test_create_apply_to.py index bdf53544514..633b34240df 100644 --- a/tests/basic/test_create_apply_to.py +++ b/tests/basic/test_create_apply_to.py @@ -1,4 +1,4 @@ -"""Tests for per-agent targeting (``Create --apply_to``).""" +"""Tests for per-agent targeting (``ManageExtensions --apply_to``).""" from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -10,7 +10,7 @@ from cecli.helpers.agents.config import SubAgentConfig from cecli.helpers.agents.service import AgentService from cecli.helpers.creator import utils as creator_utils -from cecli.tools.create import Tool +from cecli.tools.manage_extensions import Tool from cecli.tools.utils.helpers import ToolError diff --git a/tests/basic/test_create_tool.py b/tests/basic/test_create_tool.py index c70513ae9be..b260a133e19 100644 --- a/tests/basic/test_create_tool.py +++ b/tests/basic/test_create_tool.py @@ -1,4 +1,4 @@ -"""Tests for the creator-scoped Create tool.""" +"""Tests for the creator-scoped ManageExtensions tool.""" from types import SimpleNamespace from unittest.mock import AsyncMock, Mock @@ -7,8 +7,8 @@ import yaml from cecli.helpers.creator import utils as creator_utils -from cecli.tools import create as create_module -from cecli.tools.create import Tool +from cecli.tools import manage_extensions +from cecli.tools.manage_extensions import Tool from cecli.tools.utils.helpers import ToolError @@ -124,7 +124,7 @@ async def test_add_skill_writes_file_and_path(coder, creator_paths, reloads): @pytest.mark.asyncio async def test_non_creator_coder_rejected(coder, creator_paths, reloads, monkeypatch): - monkeypatch.setattr(create_module, "is_creator_coder", lambda coder: False) + monkeypatch.setattr(manage_extensions, "is_creator_coder", lambda coder: False) with pytest.raises(ToolError): await Tool.execute(coder, action="add", type="config", name="x", config=1) @@ -404,7 +404,7 @@ def registry(monkeypatch): @pytest.fixture(autouse=True) def stub_override_refresh(monkeypatch): - """Keep Create's post-write override refresh out of the real config files.""" + """Keep ManageExtensions' post-write override refresh out of the real config files.""" from cecli.helpers.agents import overrides monkeypatch.setattr(overrides, "refresh_agent_overrides", Mock(return_value={})) diff --git a/tests/integration/test_agent_mcp_management.py b/tests/integration/test_agent_mcp_management.py index ff5ef3b7a94..30ee35c2d80 100644 --- a/tests/integration/test_agent_mcp_management.py +++ b/tests/integration/test_agent_mcp_management.py @@ -7,7 +7,7 @@ from cecli.coders.agent_coder import AgentCoder from cecli.coders.sub_agent_coder import SubAgentCoder -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool @pytest.fixture @@ -104,7 +104,7 @@ async def test_agent_can_load_mcp_server(agent_coder, mock_mcp_manager): """Verify an agent can load an MCP server.""" server_name = "test_server" - await ResourceManagerTool.execute(agent_coder, load_mcp=[server_name]) + await ManageResourcesTool.execute(agent_coder, load_mcp=[server_name]) mock_mcp_manager.connect_server.assert_called_once_with(server_name) assert server_name in mock_mcp_manager.connected_servers @@ -116,7 +116,7 @@ async def test_agent_can_remove_mcp_server(agent_coder, mock_mcp_manager): server_name = "test_server" mock_mcp_manager.connected_servers[server_name] = "connected" - await ResourceManagerTool.execute(agent_coder, remove_mcp=[server_name]) + await ManageResourcesTool.execute(agent_coder, remove_mcp=[server_name]) mock_mcp_manager.disconnect_server.assert_called_once_with(server_name) assert server_name not in mock_mcp_manager.connected_servers @@ -127,7 +127,7 @@ async def test_sub_agent_can_load_mcp_server(sub_agent_coder, mock_mcp_manager): """Verify a subagent can load an MCP server.""" server_name = "sub_test_server" - await ResourceManagerTool.execute(sub_agent_coder, load_mcp=[server_name]) + await ManageResourcesTool.execute(sub_agent_coder, load_mcp=[server_name]) mock_mcp_manager.connect_server.assert_called_once_with(server_name) assert server_name in mock_mcp_manager.connected_servers @@ -139,7 +139,7 @@ async def test_sub_agent_can_remove_mcp_server(sub_agent_coder, mock_mcp_manager server_name = "sub_test_server" mock_mcp_manager.connected_servers[server_name] = "connected" - await ResourceManagerTool.execute(sub_agent_coder, remove_mcp=[server_name]) + await ManageResourcesTool.execute(sub_agent_coder, remove_mcp=[server_name]) mock_mcp_manager.disconnect_server.assert_called_once_with(server_name) assert server_name not in mock_mcp_manager.connected_servers diff --git a/tests/integration/test_mcp_management.py b/tests/integration/test_mcp_management.py index 0d5f86e4404..c2d3136766d 100644 --- a/tests/integration/test_mcp_management.py +++ b/tests/integration/test_mcp_management.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool class CoderMock: @@ -58,14 +58,14 @@ async def test_integration_load_and_remove_server(coder): coder.coroutines.interruptible = coder.mock_interruptible # Load the server - load_result = await ResourceManagerTool.execute(coder, load_mcp=["integration-test-server"]) + load_result = await ManageResourcesTool.execute(coder, load_mcp=["integration-test-server"]) assert "Loaded server: integration-test-server" in str(load_result) # Mock the connected server for the remove tool coder.mcp_manager.connected_servers = {"integration-test-server": coder.mcp_manager.servers[0]} # Remove the server - remove_result = await ResourceManagerTool.execute(coder, remove_mcp=["integration-test-server"]) + remove_result = await ManageResourcesTool.execute(coder, remove_mcp=["integration-test-server"]) assert "Removed server: integration-test-server" in str(remove_result) @@ -78,7 +78,7 @@ async def test_integration_wildcard_load_and_remove(coder): coder.coroutines.interruptible = coder.mock_interruptible # Load all enabled servers - load_result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + load_result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) assert "Loaded server: server1" in str(load_result) assert "Loaded server: server2" in str(load_result) # Non-enabled servers are filtered out silently by wildcard expansion @@ -90,6 +90,6 @@ async def test_integration_wildcard_load_and_remove(coder): } # Remove all connected servers - remove_result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) + remove_result = await ManageResourcesTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in str(remove_result) assert "Removed server: server2" in str(remove_result) diff --git a/tests/mcp/test_manager_retry.py b/tests/mcp/test_manager_retry.py index 976a7eac2d2..1e5bce8a2e1 100644 --- a/tests/mcp/test_manager_retry.py +++ b/tests/mcp/test_manager_retry.py @@ -687,9 +687,9 @@ async def test_load_session_benefits_from_retry(mock_server, mock_io, mock_tools @pytest.mark.asyncio -async def test_resource_manager_benefits_from_retry(mock_server, mock_io, mock_tools, mock_session): - """TC-021: ResourceManager _load_mcp uses connect_server which now retries.""" - from cecli.tools.resource_manager import Tool as ResourceManagerTool +async def test_manage_resources_benefits_from_retry(mock_server, mock_io, mock_tools, mock_session): + """TC-021: ManageResources _load_mcp uses connect_server which now retries.""" + from cecli.tools.manage_resources import Tool as ManageResourcesTool coder = _make_mock_coder(mock_server, mock_io) @@ -707,9 +707,9 @@ async def _connect_with_retry(name): # Mock the context block check coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} - with patch("cecli.tools.resource_manager.iter_all_coders", return_value=[coder]): - with patch("cecli.tools.resource_manager.update_server_registration"): - result = await ResourceManagerTool._load_mcp(coder, "test-server") + with patch("cecli.tools.manage_resources.iter_all_coders", return_value=[coder]): + with patch("cecli.tools.manage_resources.update_server_registration"): + result = await ManageResourcesTool._load_mcp(coder, "test-server") assert "Loaded server: test-server" in result diff --git a/tests/tools/test_create_format_output.py b/tests/tools/test_create_format_output.py index 7a72850e7cc..28092014e01 100644 --- a/tests/tools/test_create_format_output.py +++ b/tests/tools/test_create_format_output.py @@ -1,9 +1,9 @@ -"""Tests for Create.format_output rendering.""" +"""Tests for ManageExtensions.format_output rendering.""" import json from types import SimpleNamespace -from cecli.tools import create +from cecli.tools import manage_extensions class DummyIO: @@ -31,14 +31,14 @@ def make_tool_response(args): id="test-id", type="function", function=SimpleNamespace( - name="Create", + name="ManageExtensions", arguments=json.dumps(args), ), ) def render(coder, args): - create.Tool.format_output( + manage_extensions.Tool.format_output( coder, mcp_server=SimpleNamespace(name="Local"), tool_response=make_tool_response(args), @@ -96,10 +96,10 @@ def test_format_output_reports_invalid_arguments(): tool_response = SimpleNamespace( id="test-id", type="function", - function=SimpleNamespace(name="Create", arguments="{not json"), + function=SimpleNamespace(name="ManageExtensions", arguments="{not json"), ) - create.Tool.format_output( + manage_extensions.Tool.format_output( coder, mcp_server=SimpleNamespace(name="Local"), tool_response=tool_response, diff --git a/tests/tools/test_resource_manager_paging.py b/tests/tools/test_manage_resources_paging.py similarity index 89% rename from tests/tools/test_resource_manager_paging.py rename to tests/tools/test_manage_resources_paging.py index 1237b29dc6e..d5b86312004 100644 --- a/tests/tools/test_resource_manager_paging.py +++ b/tests/tools/test_manage_resources_paging.py @@ -1,4 +1,4 @@ -"""ResourceManager paging returns command output without adding context files.""" +"""ManageResources paging returns command output without adding context files.""" import json from pathlib import Path @@ -7,7 +7,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool from cecli.tools.utils.helpers import ToolError from cecli.tools.utils.responses import ToolResponse @@ -40,7 +40,6 @@ def operations(monkeypatch): "_remove", "_view", "_editable", - "_stop_command", "_load_skill", "_remove_skill", "_load_mcp", @@ -49,7 +48,7 @@ def operations(monkeypatch): ): mock_type = AsyncMock if name in ("_load_mcp", "_remove_mcp", "_list_mcp_servers") else Mock mocks[name] = mock_type(return_value="operation completed") - monkeypatch.setattr(ResourceManagerTool, name, mocks[name]) + monkeypatch.setattr(ManageResourcesTool, name, mocks[name]) return mocks @@ -66,7 +65,7 @@ async def test_paging_returns_page_contents_without_adding_context(coder, target coder.abs_root_path.reset_mock() coder.local_agent_folder.reset_mock() - response = await ResourceManagerTool.execute(coder, paging=[{"target": target, "page": page}]) + response = await ManageResourcesTool.execute(coder, paging=[{"target": target, "page": page}]) assert isinstance(response, ToolResponse) result = response.to_dict() @@ -85,7 +84,7 @@ async def test_missing_page_appends_response_error(coder): editable_before = coder.abs_fnames.copy() read_only_before = coder.abs_read_only_fnames.copy() - response = await ResourceManagerTool.execute(coder, paging=[{"target": "bg_1_1234", "page": 1}]) + response = await ManageResourcesTool.execute(coder, paging=[{"target": "bg_1_1234", "page": 1}]) assert isinstance(response, ToolResponse) result = response.to_dict() @@ -116,7 +115,7 @@ async def test_missing_page_appends_response_error(coder): ) async def test_invalid_paging_object_raises_before_operations(coder, operations, paging): with pytest.raises(ToolError): - await ResourceManagerTool.execute(coder, paging=paging, create=["untouched.txt"]) + await ManageResourcesTool.execute(coder, paging=paging, create=["untouched.txt"]) for operation in operations.values(): operation.assert_not_called() @@ -144,7 +143,7 @@ async def test_invalid_paging_object_raises_before_operations(coder, operations, ) async def test_invalid_paging_target_raises_before_operations(coder, operations, target): with pytest.raises(ToolError): - await ResourceManagerTool.execute( + await ManageResourcesTool.execute( coder, paging=[{"target": target, "page": 1}], create=["untouched.txt"] ) @@ -156,7 +155,7 @@ async def test_invalid_paging_target_raises_before_operations(coder, operations, @pytest.mark.parametrize("page", [0, -1, 1.0, 1.5, "1", True, False, None, [], {}]) async def test_invalid_page_number_raises_before_operations(coder, operations, page): with pytest.raises(ToolError): - await ResourceManagerTool.execute( + await ManageResourcesTool.execute( coder, paging=[{"target": "bg_1_1234", "page": page}], create=["untouched.txt"] ) @@ -183,7 +182,7 @@ async def test_command_key_alias_rejected_before_any_operations(coder, operation kwargs[action].append(alias) with pytest.raises(ToolError): - await ResourceManagerTool.execute(coder, **kwargs) + await ManageResourcesTool.execute(coder, **kwargs) for operation in operations.values(): operation.assert_not_called() @@ -192,7 +191,7 @@ async def test_command_key_alias_rejected_before_any_operations(coder, operation def test_paging_schema_requires_exact_target_and_positive_integer_page(): - parameters = ResourceManagerTool.SCHEMA["function"]["parameters"] + parameters = ManageResourcesTool.SCHEMA["function"]["parameters"] paging = parameters["properties"]["paging"] assert paging["type"] == "array" @@ -214,12 +213,12 @@ def test_format_output_shows_paging_target_and_page(coder): id="test-paging", type="function", function=SimpleNamespace( - name="ResourceManager", + name="ManageResources", arguments=json.dumps({"paging": [{"target": "bg_1_1234", "page": 7}]}), ), ) - ResourceManagerTool.format_output(coder, SimpleNamespace(name="Local"), tool_response) + ManageResourcesTool.format_output(coder, SimpleNamespace(name="Local"), tool_response) output = "\n".join( str(call.args[0]) for call in coder.io.tool_output.call_args_list if call.args @@ -241,7 +240,7 @@ async def test_three_pages_return_in_order_without_context_changes(coder): editable_before = coder.abs_fnames.copy() read_only_before = coder.abs_read_only_fnames.copy() - response = await ResourceManagerTool.execute(coder, paging=paging) + response = await ManageResourcesTool.execute(coder, paging=paging) result = response.to_dict() assert result["errors"] == [] @@ -259,7 +258,7 @@ async def test_invalid_later_entry_validated_before_reading_or_operations(coder, paging = [{"target": "bg_1_1234", "page": 1}, {"target": "bg_1_1234", "page": False}] with pytest.raises(ToolError): - await ResourceManagerTool.execute(coder, paging=paging, create=["untouched.txt"]) + await ManageResourcesTool.execute(coder, paging=paging, create=["untouched.txt"]) coder.local_agent_folder.assert_not_called() coder.abs_root_path.assert_not_called() @@ -275,13 +274,13 @@ async def test_command_large_output_guidance_uses_paging_array( import asyncio from cecli.helpers import background_commands - from cecli.tools import command + from cecli.tools import shell coder.root = str(tmp_path) coder.large_file_token_threshold = 10 coder.context_management_enabled = True coder.interrupt_event = asyncio.Event() - manager = command.BackgroundCommandManager + manager = shell.BackgroundCommandManager target = "bg_1_1234" output = "large command output\n" * 50 monkeypatch.setattr(manager, "_generate_command_key", Mock(return_value=target)) @@ -289,8 +288,8 @@ async def test_command_large_output_guidance_uses_paging_array( monkeypatch.setattr(manager, "save_paginated_output", save) if execution_path == "foreground": - monkeypatch.setattr(command, "run_cmd_subprocess", Mock(return_value=(0, output))) - response = await command.Tool._execute_foreground(coder, "echo test") + monkeypatch.setattr(shell, "run_cmd_subprocess", Mock(return_value=(0, output))) + response = await shell.Tool._execute_foreground(coder, "echo test") else: process = Mock() process.wait.return_value = 0 @@ -306,14 +305,14 @@ def make_buffer(page_size=4096, pages_dir=None): return buffer monkeypatch.setattr(background_commands, "PagedOutputBuffer", make_buffer) - response = await command.Tool._execute_with_timeout(coder, "echo test", 30, use_pty=False) + response = await shell.Tool._execute_with_timeout(coder, "echo test", 30, use_pty=False) result = response.to_dict() assert result["errors"] == [] content = result["result"][0]["content"] example = next(line for line in content.splitlines() if line.startswith('{"paging"')) assert json.loads(example) == {"paging": [{"target": target, "page": 1}]} - assert "ResourceManager" in content + assert "ManageResources" in content assert "command_key::" not in content assert "not added to file context" in content diff --git a/tests/tools/test_registry.py b/tests/tools/test_registry.py index 80985f5fbfe..27ee66602af 100644 --- a/tests/tools/test_registry.py +++ b/tests/tools/test_registry.py @@ -30,7 +30,7 @@ def test_registry_initialization(self): assert len(tools) > 0, "Registry should have tools after initialization" # Check that essential tools are registered - essential_tools = {"resourcemanager", "readfile", "yield"} + essential_tools = {"manageresources", "readfile", "yield"} for tool in essential_tools: assert tool in tools, f"Essential tool {tool} should be registered" @@ -47,10 +47,10 @@ def test_ls_tool_removed(self): def test_get_tool(self): """Test getting individual tools by name""" # Get existing tool - tool_class = ToolRegistry.get_tool("resourcemanager") - assert tool_class is not None, "Should get resourcemanager tool" + tool_class = ToolRegistry.get_tool("manageresources") + assert tool_class is not None, "Should get manageresources tool" assert hasattr(tool_class, "NORM_NAME"), "Tool class should have NORM_NAME" - assert tool_class.NORM_NAME == "resourcemanager", "Tool name should match" + assert tool_class.NORM_NAME == "manageresources", "Tool name should match" # Get non-existent tool non_existent = ToolRegistry.get_tool("nonexistenttool") @@ -64,107 +64,107 @@ def test_build_registry_empty_config(self): assert len(registry) > 0, "Should return tools with empty config" # Essential tools should always be included - assert "resourcemanager" in registry, "Essential tool should be included" + assert "manageresources" in registry, "Essential tool should be included" assert "readfile" in registry, "Essential tool should be included" assert "yield" in registry, "Essential tool should be included" def test_build_registry_with_includelist(self): """Test filtering with tools_includelist""" - config = {"tools_includelist": ["resourcemanager", "editfile"]} + config = {"tools_includelist": ["manageresources", "editfile"]} registry = ToolRegistry.build_registry(config) # Should only include tools from includelist, plus essential tools assert len(registry) == 3, "Should include 2 from list + 1 essential" - assert "resourcemanager" in registry + assert "manageresources" in registry assert "editfile" in registry assert "yield" in registry # Essential - assert "command" not in registry, "Should not include tools not in includelist" + assert "shell" not in registry, "Should not include tools not in includelist" def test_build_registry_with_excludelist(self): """Test filtering with tools_excludelist""" - config = {"tools_excludelist": ["command", "commandinteractive"]} + config = {"tools_excludelist": ["shell", "commandinteractive"]} registry = ToolRegistry.build_registry(config) # Should exclude specified tools (except essentials) - assert "command" not in registry, "Should exclude command" + assert "shell" not in registry, "Should exclude shell" assert "commandinteractive" not in registry, "Should exclude commandinteractive" - assert "resourcemanager" in registry, "Essential tool should still be included" + assert "manageresources" in registry, "Essential tool should still be included" def test_build_registry_exclude_essential(self): """Test that essential tools cannot be excluded""" - config = {"tools_excludelist": ["resourcemanager", "readfile", "finished", "command"]} + config = {"tools_excludelist": ["manageresources", "readfile", "finished", "shell"]} registry = ToolRegistry.build_registry(config) # Essential tools should still be included despite excludelist - assert "resourcemanager" not in registry, "Non-essential tool can be excluded" + assert "manageresources" not in registry, "Non-essential tool can be excluded" assert "readfile" not in registry, "Non-essential tool can be excluded" assert "yield" in registry, "Essential tool cannot be excluded" - assert "command" not in registry, "Non-essential tool should be excluded" + assert "shell" not in registry, "Non-essential tool should be excluded" def test_build_registry_combined_filters(self): """Test combined filtering with includelist and excludelist""" config = { - "tools_includelist": ["resourcemanager", "editfile", "command"], + "tools_includelist": ["manageresources", "editfile", "shell"], "tools_excludelist": ["commandinteractive"], } registry = ToolRegistry.build_registry(config) # Should respect all filters assert len(registry) == 4, "Should include exactly 4 tools (3 from list + yield)" - assert "resourcemanager" in registry + assert "manageresources" in registry assert "editfile" in registry assert "yield" in registry - assert "command" in registry + assert "shell" in registry assert "commandinteractive" not in registry def test_get_filtered_tools(self): """Test get_filtered_tools method""" - config = {"tools_includelist": ["resourcemanager", "editfile"]} + config = {"tools_includelist": ["manageresources", "editfile"]} ToolRegistry.build_registry(config) tool_names = ToolRegistry.get_registered_tools() # Should return list of tool names assert isinstance(tool_names, list) - # Should include resourcemanager, editfile, and yield (essentials) + # Should include manageresources, editfile, and yield (essentials) assert len(tool_names) == 3 - assert "resourcemanager" in tool_names + assert "manageresources" in tool_names assert "editfile" in tool_names assert "yield" in tool_names # Essential tool always included def test_legacy_config_names(self): """Test backward compatibility with legacy config names (whitelist/blacklist)""" config = { - "tools_whitelist": ["resourcemanager", "editfile"], - "tools_blacklist": ["command"], + "tools_whitelist": ["manageresources", "editfile"], + "tools_blacklist": ["shell"], } registry = ToolRegistry.build_registry(config) # Should work with legacy names - assert "resourcemanager" in registry + assert "manageresources" in registry assert "editfile" in registry - assert "command" not in registry + assert "shell" not in registry def test_config_precedence(self): """Test that new config names take precedence over legacy names""" config = { - "tools_includelist": ["resourcemanager"], - "tools_whitelist": ["command"], # Should be ignored + "tools_includelist": ["manageresources"], + "tools_whitelist": ["shell"], # Should be ignored "tools_excludelist": ["commandinteractive"], "tools_blacklist": ["finished"], # Should be ignored for essential tool } registry = ToolRegistry.build_registry(config) # New names should take precedence - assert "resourcemanager" in registry, "Should use tools_includelist" + assert "manageresources" in registry, "Should use tools_includelist" assert ( - "command" not in registry + "shell" not in registry ), "Should not use tools_whitelist when tools_includelist present" assert "commandinteractive" not in registry, "Should use tools_excludelist" assert "yield" in registry, "Essential tool cannot be excluded" def test_registry_consistency(self): """Test that registry methods return consistent results""" - config = {"tools_includelist": ["resourcemanager", "editfile"]} + config = {"tools_includelist": ["manageresources", "editfile"]} # build_registry should return consistent results registry = ToolRegistry.build_registry(config) @@ -179,9 +179,9 @@ def test_skill_and_mcp_tools_in_context_manager(self): """Test that skill/MCP functionality is now part of context_manager.""" # The individual load_skill, remove_skill, load_mcp, remove_mcp tools # have been merged into context_manager - ctx_tool = ToolRegistry.get_tool("resourcemanager") - assert ctx_tool is not None, "resourcemanager tool should be registered" - assert ctx_tool.NORM_NAME == "resourcemanager" + ctx_tool = ToolRegistry.get_tool("manageresources") + assert ctx_tool is not None, "manageresources tool should be registered" + assert ctx_tool.NORM_NAME == "manageresources" # Verify the merged tools no longer exist as separate entries assert ToolRegistry.get_tool("loadskill") is None diff --git a/tests/tools/test_remove_mcp_tool.py b/tests/tools/test_remove_mcp_tool.py index 2da2e85e37c..837d44a6dc4 100644 --- a/tests/tools/test_remove_mcp_tool.py +++ b/tests/tools/test_remove_mcp_tool.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool class DummyIO: @@ -54,7 +54,7 @@ class TestRemoveMcpTool: def _patch_global_exclusion(self): """Patch is_server_globally_excluded to prevent test isolation issues with AgentService.""" with patch( - "cecli.tools.resource_manager.is_server_globally_excluded", + "cecli.tools.manage_resources.is_server_globally_excluded", return_value=True, ): yield @@ -63,7 +63,7 @@ def _patch_global_exclusion(self): async def test_no_configured_servers(self, coder): """Test when no MCP servers are configured at all.""" coder.mcp_manager.servers = [] - result = await ResourceManagerTool.execute(coder, remove_mcp=["test"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test"]) assert result.to_dict()["result"][0]["content"] == "No MCP servers are configured." @pytest.mark.asyncio @@ -72,7 +72,7 @@ async def test_server_not_found(self, coder, mock_server): coder.mcp_manager.servers = [mock_server] coder.mcp_manager.connected_servers = {"existing": "server"} coder.mcp_manager.get_server.return_value = None - result = await ResourceManagerTool.execute(coder, remove_mcp=["nonexistent"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["nonexistent"]) assert "MCP server nonexistent does not exist." in str(result) @pytest.mark.asyncio @@ -81,7 +81,7 @@ async def test_all_servers_not_loaded(self, coder, mock_server): coder.mcp_manager.servers = [mock_server] coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Server test-server is not currently connected." in str(result) @pytest.mark.asyncio @@ -91,7 +91,7 @@ async def test_successful_removal(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (True, False) - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Removed server: test-server" in str(result) @pytest.mark.asyncio @@ -101,7 +101,7 @@ async def test_removal_interrupted(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, True) - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Interrupted: test-server" in str(result) @pytest.mark.asyncio @@ -111,7 +111,7 @@ async def test_removal_failed(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, False) - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Unable to remove server: test-server" in str(result) @pytest.mark.asyncio @@ -127,7 +127,7 @@ async def test_remove_all_servers(self, coder): (s for s in [server1, server2] if s.name == name), None ) coder.coroutines.interruptible.return_value = (True, False) - result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in str(result) assert "Removed server: server2" in str(result) @@ -152,6 +152,6 @@ async def mock_interruptible_func(*args, **kwargs): return result coder.coroutines.interruptible.side_effect = mock_interruptible_func - result = await ResourceManagerTool.execute(coder, remove_mcp=["server1", "server2"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["server1", "server2"]) assert "Removed server: server1" in str(result) assert "Unable to remove server: server2" in str(result) diff --git a/tests/tools/test_command_format_output.py b/tests/tools/test_shell_format_output.py similarity index 92% rename from tests/tools/test_command_format_output.py rename to tests/tools/test_shell_format_output.py index 7e5b3423dd0..f91ecc28a37 100644 --- a/tests/tools/test_command_format_output.py +++ b/tests/tools/test_shell_format_output.py @@ -1,4 +1,4 @@ -"""Regression tests for Command.format_output rendering. +"""Regression tests for Shell.format_output rendering. format_output() prints the "Tool Call:" header before execute() runs, so it must clearly communicate what execute() is about to do — including the case @@ -14,7 +14,7 @@ import pytest -from cecli.tools import command +from cecli.tools import shell class DummyIO: @@ -45,14 +45,14 @@ def make_tool_response(args): id="test-id", type="function", function=SimpleNamespace( - name="Command", + name="Shell", arguments=json.dumps(args), ), ) def render(coder, args): - command.Tool.format_output( + shell.Tool.format_output( coder, mcp_server=SimpleNamespace(name="Local"), tool_response=make_tool_response(args), @@ -72,7 +72,7 @@ def test_format_output_empty_args_shows_explicit_placeholder(): """Regression test: an empty-args call (no command, no background_key/action) must not render a header with a silently blank body. - Reproduces a real session where the model called `Command` with `{}` — + Reproduces a real session where the model called `Shell` with `{}` — execute() correctly rejects this ("'command' must be provided."), but the displayed panel showed nothing after the header, which was mistaken for a display bug (auto-approved commands failing to render their body). @@ -111,7 +111,7 @@ def __init__(self): self.agent_config = {} coder = ExecCoder() - response = await command.Tool.execute(coder) + response = await shell.Tool.execute(coder) result = response.to_dict() assert result["result"] == [] assert "'command' must be provided." in result["errors"] diff --git a/tests/tools/test_command_timeout_paging.py b/tests/tools/test_shell_timeout_paging.py similarity index 96% rename from tests/tools/test_command_timeout_paging.py rename to tests/tools/test_shell_timeout_paging.py index c4fb654a12f..2abf4e42204 100644 --- a/tests/tools/test_command_timeout_paging.py +++ b/tests/tools/test_shell_timeout_paging.py @@ -10,8 +10,8 @@ import pytest_asyncio from cecli.helpers import background_commands -from cecli.tools.command import Tool as CommandTool -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool +from cecli.tools.shell import Tool as ShellTool @pytest_asyncio.fixture @@ -70,7 +70,7 @@ async def execute(output, threshold=8, enabled=True): coder.large_file_token_threshold = threshold coder.context_management_enabled = enabled state["output"] = output - response = await CommandTool._execute_with_timeout( + response = await ShellTool._execute_with_timeout( coder, "pending command", 0.001, use_pty=False ) result = response.to_dict() @@ -116,7 +116,7 @@ async def test_elapsed_timeout_pages_output_and_exposes_command_keys(elapsed_com assert num_pages >= 1 assert output not in content assert f"Output paged to disk: {num_pages} page(s)." in content - assert "ResourceManager" in content + assert "ManageResources" in content assert "not added to file context" in content assert "command_key::" not in content example = next(line for line in content.splitlines() if line.startswith('{"paging"')) @@ -133,7 +133,7 @@ async def test_elapsed_timeout_pages_output_and_exposes_command_keys(elapsed_com editable_before = coder.abs_fnames.copy() read_only_before = coder.abs_read_only_fnames.copy() - response = await ResourceManagerTool.execute(coder, paging=[{"target": target, "page": 1}]) + response = await ManageResourcesTool.execute(coder, paging=[{"target": target, "page": 1}]) result = response.to_dict() assert result["errors"] == [] assert result["result"][0]["content"].endswith(output[:page_size]) diff --git a/tests/tools/test_tools_load_mcp_tool.py b/tests/tools/test_tools_load_mcp_tool.py index 6e9071cb711..2397bb2b2dd 100644 --- a/tests/tools/test_tools_load_mcp_tool.py +++ b/tests/tools/test_tools_load_mcp_tool.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool class DummyIO: @@ -54,7 +54,7 @@ class TestLoadMcpTool: async def test_no_mcp_servers_found(self, coder): """Test when no MCP servers are configured.""" coder.mcp_manager.servers = [] - result = await ResourceManagerTool.execute(coder, load_mcp=["test"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test"]) assert result.to_dict()["result"][0]["content"] == "No MCP servers found, nothing to load." @pytest.mark.asyncio @@ -62,7 +62,7 @@ async def test_server_not_found(self, coder, mock_server): """Test when requested server doesn't exist.""" coder.mcp_manager.servers = [mock_server] coder.mcp_manager.get_server.return_value = None - result = await ResourceManagerTool.execute(coder, load_mcp=["nonexistent"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["nonexistent"]) assert "MCP server nonexistent does not exist." in str(result) @pytest.mark.asyncio @@ -74,7 +74,7 @@ async def test_server_already_loaded(self, coder, mock_server): coder.mcp_manager.get_server.return_value = mock_server # Must return tuple (did_connect, interrupted) coder.coroutines.interruptible.return_value = (True, False) - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Server already loaded: test-server" in str(result) @pytest.mark.asyncio @@ -83,7 +83,7 @@ async def test_server_not_enabled_by_default(self, coder, mock_server): mock_server.config = {"enabled": False} coder.mcp_manager.servers = [mock_server] coder.mcp_manager.get_server.return_value = mock_server - await ResourceManagerTool.execute(coder, load_mcp=["*"]) + await ManageResourcesTool.execute(coder, load_mcp=["*"]) @pytest.mark.asyncio async def test_successful_load(self, coder, mock_server): @@ -92,7 +92,7 @@ async def test_successful_load(self, coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (True, False) - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Loaded server: test-server" in str(result) @pytest.mark.asyncio @@ -102,7 +102,7 @@ async def test_load_interrupted(self, coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, True) - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Interrupted: test-server" in str(result) @pytest.mark.asyncio @@ -112,7 +112,7 @@ async def test_load_failed(self, coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.coroutines.interruptible.return_value = (False, False) - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Unable to load server: test-server" in str(result) @pytest.mark.asyncio @@ -130,7 +130,7 @@ async def test_load_all_servers(self, coder): (s for s in [server1, server2] if s.name == name), None ) coder.coroutines.interruptible.return_value = (True, False) - result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) assert "Loaded server: server1" in str(result) assert "Loaded server: server2" in str(result) @@ -160,7 +160,7 @@ async def mock_interruptible_func(*args, **kwargs): return (False, False) coder.coroutines.interruptible.side_effect = mock_interruptible_func - result = await ResourceManagerTool.execute(coder, load_mcp=["server1", "server2"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["server1", "server2"]) assert "Loaded server: server1" in str(result) assert "Unable to load server: server2" in str(result) @@ -173,7 +173,7 @@ async def test_duplicate_iteration_bug_fix(self, coder, mock_server): coder.mcp_manager.connected_servers = {"test-server": mock_server} coder.mcp_manager.get_server.return_value = mock_server - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) # Should only report server already loaded once assert str(result).count("Server already loaded: test-server") == 1 @@ -210,7 +210,7 @@ async def mock_interruptible(coro, event): coder.mcp_manager.connect_server = AsyncMock(side_effect=mock_connect_server) coder.coroutines.interruptible = AsyncMock(side_effect=mock_interruptible) - result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) # Should only attempt to load server2 (server1 should be skipped) # Wildcard expansion skips already-connected servers, so server1 is not reported diff --git a/tests/unit/test_load_mcp.py b/tests/unit/test_load_mcp.py index 17380a33252..e5c536a40db 100644 --- a/tests/unit/test_load_mcp.py +++ b/tests/unit/test_load_mcp.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool class DummyIO: @@ -50,7 +50,7 @@ def mock_server(): async def test_no_mcp_servers_found(coder): """Test when no MCP servers are configured.""" coder.mcp_manager.servers = [] - result = await ResourceManagerTool.execute(coder, load_mcp=["test"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test"]) assert result.to_dict()["result"][0]["content"] == "No MCP servers found, nothing to load." @@ -59,7 +59,7 @@ async def test_server_not_found(coder, mock_server): """Test when requested server doesn't exist.""" coder.mcp_manager.servers = [mock_server] coder.mcp_manager.get_server.return_value = None - result = await ResourceManagerTool.execute(coder, load_mcp=["nonexistent"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["nonexistent"]) assert "MCP server nonexistent does not exist." in str(result) @@ -77,7 +77,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Server already loaded: test-server" in str(result) # connect_server should not have been called since it was already loaded coder.mcp_manager.connect_server.assert_not_called() @@ -91,7 +91,7 @@ async def test_server_not_enabled_by_default(coder, mock_server): coder.mcp_manager.connected_servers = {} coder.mcp_manager.get_server.return_value = mock_server coder.mcp_manager.connect_server = AsyncMock() - result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) # Non-enabled servers are silently filtered by wildcard expansion # Result is an empty list since nothing was loaded assert result.to_dict()["result"] == [] @@ -115,7 +115,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Loaded server: test-server" in str(result) @@ -137,7 +137,7 @@ async def mock_interruptible(coro, event): return False, True coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Interrupted: test-server" in str(result) @@ -156,7 +156,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Unable to load server: test-server" in str(result) @@ -186,7 +186,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) assert "Loaded server: server1" in str(result) assert "Loaded server: server2" in str(result) @@ -221,7 +221,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["server1", "server2"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["server1", "server2"]) assert "Loaded server: server1" in str(result) assert "Unable to load server: server2" in str(result) @@ -236,7 +236,7 @@ async def test_duplicate_iteration_bug_fix(coder, mock_server): coder.mcp_manager.get_server.return_value = mock_server # Set up connect_server as AsyncMock coder.mcp_manager.connect_server = AsyncMock() - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) # Should only report server already loaded once assert str(result).count("Server already loaded: test-server") == 1 # connect_server should not have been called since it was already loaded @@ -273,7 +273,7 @@ async def mock_interruptible(coro, event): return await coro, False coder.coroutines.interruptible = mock_interruptible - result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) # Should only attempt to load server2 (server1 should be skipped) # server1 is already connected so it's skipped silently by wildcard expansion assert "Server already loaded: server1" not in str(result) diff --git a/tests/unit/test_remove_mcp.py b/tests/unit/test_remove_mcp.py index 557baf36bf7..d2b63e9b5c6 100644 --- a/tests/unit/test_remove_mcp.py +++ b/tests/unit/test_remove_mcp.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool class DummyIO: @@ -70,7 +70,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() # Execute - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) # Assertions assert "Removed server: test-server" in str(result) coder.mcp_manager.disconnect_server.assert_awaited_once_with("test-server") @@ -90,7 +90,7 @@ async def test_remove_mcp_tool_non_existent(): # But the one we're looking for doesn't exist coder.mcp_manager.get_server.return_value = None # Execute - result = await ResourceManagerTool.execute(coder, remove_mcp=["non-existent-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["non-existent-server"]) # Assertions assert "MCP server non-existent-server does not exist." in str(result) @@ -106,7 +106,7 @@ async def test_remove_mcp_tool_not_connected(): coder.mcp_manager.servers = [server] coder.mcp_manager.get_server.return_value = server coder.mcp_manager.connected_servers = {} - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Server test-server is not currently connected." in str(result) @@ -139,7 +139,7 @@ async def mock_interruptible(coro, event): coder.coroutines = MagicMock() coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in str(result) assert "Removed server: server2" in str(result) @@ -166,7 +166,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Interrupted: test-server" in str(result) @@ -192,7 +192,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Unable to remove server: test-server" in str(result) @@ -203,7 +203,7 @@ async def test_remove_mcp_tool_no_servers_configured(): coder.agent_config = {"include_context_blocks": {"servers"}, "exclude_context_blocks": set()} coder.mcp_manager = MagicMock() coder.mcp_manager.servers = [] - result = await ResourceManagerTool.execute(coder, remove_mcp=["test"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test"]) assert result.to_dict()["result"][0]["content"] == "No MCP servers are configured." @@ -237,7 +237,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["server1", "server2"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["server1", "server2"]) assert "Removed server: server1" in str(result) assert "Unable to remove server: server2" in str(result) @@ -268,7 +268,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["*"]) # Should successfully remove both servers using dictionary keys assert "Removed server: server1" in str(result) assert "Removed server: server2" in str(result) diff --git a/tests/unit/test_unit_load_mcp_tool.py b/tests/unit/test_unit_load_mcp_tool.py index 0376ac79284..689d09d4257 100644 --- a/tests/unit/test_unit_load_mcp_tool.py +++ b/tests/unit/test_unit_load_mcp_tool.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool @pytest.fixture @@ -73,7 +73,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible.side_effect = mock_interruptible coder.interrupt_event = MagicMock() coder.registered_servers = {"included": set(), "excluded": set()} - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Loaded server: test-server" in str(result) mock_mcp_manager.connect_server.assert_awaited_once_with("test-server") @@ -88,7 +88,7 @@ async def test_load_mcp_tool_non_existent(mock_mcp_manager): coder.mcp_manager = mock_mcp_manager coder.registered_servers = {"included": set(), "excluded": set()} - result = await ResourceManagerTool.execute(coder, load_mcp=["non-existent-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["non-existent-server"]) assert "MCP server non-existent-server does not exist." in str(result) mock_mcp_manager.connect_server.assert_not_awaited() @@ -105,7 +105,7 @@ async def test_load_mcp_tool_already_loaded(mock_mcp_manager): server = mock_mcp_manager.get_server("test-server") coder.mcp_manager.connected_servers = {"test-server": server} - result = await ResourceManagerTool.execute(coder, load_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["test-server"]) assert "Server already loaded: test-server" in str(result) mock_mcp_manager.connect_server.assert_not_awaited() @@ -132,7 +132,7 @@ async def mock_interruptible(coro, event): server1 = mock_mcp_manager.get_server("test-server") coder.mcp_manager.connected_servers = {"test-server": server1} - result = await ResourceManagerTool.execute(coder, load_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, load_mcp=["*"]) # Check results # Wildcard expansion skips already-connected servers; no "already loaded" message is produced diff --git a/tests/unit/test_unit_remove_mcp_tool.py b/tests/unit/test_unit_remove_mcp_tool.py index aab2f93e500..ce7b1256870 100644 --- a/tests/unit/test_unit_remove_mcp_tool.py +++ b/tests/unit/test_unit_remove_mcp_tool.py @@ -4,7 +4,7 @@ import pytest -from cecli.tools.resource_manager import Tool as ResourceManagerTool +from cecli.tools.manage_resources import Tool as ManageResourcesTool class DummyIO: @@ -72,7 +72,7 @@ async def mock_interruptible(coro, event): coder.interrupt_event = MagicMock() # Execute - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) # Assertions assert "Removed server: test-server" in str(result) @@ -95,7 +95,7 @@ async def test_remove_mcp_tool_non_existent(): coder.mcp_manager.get_server.return_value = None # Execute - result = await ResourceManagerTool.execute(coder, remove_mcp=["non-existent-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["non-existent-server"]) # Assertions assert "MCP server non-existent-server does not exist." in str(result) @@ -114,7 +114,7 @@ async def test_remove_mcp_tool_not_connected(): coder.mcp_manager.get_server.return_value = server coder.mcp_manager.connected_servers = {} - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Server test-server is not currently connected." in str(result) @@ -150,7 +150,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["*"]) assert "Removed server: server1" in str(result) assert "Removed server: server2" in str(result) @@ -180,7 +180,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Interrupted: test-server" in str(result) @@ -209,7 +209,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["test-server"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test-server"]) assert "Unable to remove server: test-server" in str(result) @@ -223,7 +223,7 @@ async def test_remove_mcp_tool_no_servers_configured(): coder.registered_servers = {"included": set(), "excluded": set()} coder.mcp_manager.servers = [] - result = await ResourceManagerTool.execute(coder, remove_mcp=["test"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["test"]) assert result.to_dict()["result"][0]["content"] == "No MCP servers are configured." @@ -261,7 +261,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["server1", "server2"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["server1", "server2"]) assert "Removed server: server1" in str(result) assert "Unable to remove server: server2" in str(result) @@ -295,7 +295,7 @@ async def mock_interruptible(coro, event): coder.coroutines.interruptible = mock_interruptible coder.interrupt_event = MagicMock() - result = await ResourceManagerTool.execute(coder, remove_mcp=["*"]) + result = await ManageResourcesTool.execute(coder, remove_mcp=["*"]) # Should successfully remove both servers using dictionary keys assert "Removed server: server1" in str(result) From 6cb39a94dd6c05a6bdd9620c106da6a671fa637b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 29 Oct 2026 17:34:34 -0700 Subject: [PATCH 03/13] cli-73: added more aggressive auto compaction, potential token savings must be greater than or equal to %50 of the tokens over the limit, OR the compatctable tokens makes up greather than 15% of all tokens --- cecli/coders/base_coder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index e9d6db300b7..1fb8b75dddc 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2297,13 +2297,13 @@ async def compact_context_if_needed(self, force=False, message=""): is_min_size = all_tokens >= self.context_compaction_max_tokens # Condition 2: Percentage check (is chat history a significant part of the context?) - is_worth_by_percentage = all_tokens > 0 and (compactable_tokens / all_tokens) >= 0.20 + is_worth_by_percentage = all_tokens > 0 and (compactable_tokens / all_tokens) >= 0.15 # Condition 3: Absolute savings check (would compacting save enough tokens to fit?) tokens_over_limit = all_tokens - (self.context_compaction_max_tokens or all_tokens) potential_savings = compactable_tokens * 0.90 is_worth_by_absolute_savings = ( - tokens_over_limit > 0 and potential_savings >= tokens_over_limit + tokens_over_limit > 0 and potential_savings >= (tokens_over_limit * 0.50) ) if not force: From 0238c47a5c75991c1b2ac50a3034bb871e16a3b1 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 30 Oct 2026 18:48:30 -0400 Subject: [PATCH 04/13] Remove overzealous compation messages --- cecli/coders/base_coder.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 26ca44331a6..c44fa54c096 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2790,10 +2790,6 @@ async def check_tokens(self, messages): self.enable_context_compaction and input_tokens >= self.context_compaction_max_tokens * 0.95 ): - self.io.tool_output( - f"Estimated chat context of {input_tokens:,} tokens exceeds the" - f" {max_input_tokens:,} token limit. Attempting to compact..." - ) await self.compact_context_if_needed() # After compaction, re-format messages and re-check tokens @@ -2807,13 +2803,12 @@ async def check_tokens(self, messages): if getattr(self, "_last_compaction_warning_time", 0) + 300 < time.time(): self._last_compaction_warning_time = time.time() self.io.tool_error( - f"Your estimated chat context of {input_tokens:,} tokens still exceeds the" - f" {max_input_tokens:,} token limit for {self.get_active_model().name}!" + f"Your estimated chat context of {input_tokens:,} tokens exceeds the" + f" {max_input_tokens:,} token limit for {self.get_active_model().name}" ) self.io.tool_output("To reduce the chat context:") self.io.tool_output("- Use /drop to remove unneeded files from the chat") self.io.tool_output("- Use /clear to clear the chat history") - self.io.tool_output("- Break your code into smaller files") self.io.tool_output( "It's probably safe to try and send the request, most providers won't charge if" " the context limit is exceeded." From 1de3f008bc089f5188ba6e5ab1cee56be4256009 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 30 Oct 2026 18:55:54 -0400 Subject: [PATCH 05/13] Rewrite user-specified key bindings into Textual's specified order --- cecli/tui/app.py | 182 +++++++++++++------------------- cecli/tui/widgets/input_area.py | 30 ------ tests/tui/test_key_bindings.py | 55 ++++++++++ 3 files changed, 126 insertions(+), 141 deletions(-) create mode 100644 tests/tui/test_key_bindings.py diff --git a/cecli/tui/app.py b/cecli/tui/app.py index f685c9d46d0..d51505184cd 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -45,6 +45,8 @@ if platform.system() == "Windows": IS_WINDOWS = True +MODIFIER_ORDER = ("alt", "ctrl", "shift", "super") + logger = logging.getLogger(__name__) @@ -122,116 +124,39 @@ def __init__(self, coder_worker, output_queue, input_queue, args): if other.get("use_terminal_background", True): patch_textual_strip_render_with_cache() - self.bind( - self._get_binding_keys("newline"), - "noop", - description="New Line", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("submit")), "noop", description="Submit", show=True - ) - self.bind( - self._encode_keys(self.get_keys_for("cycle_forward")), - "noop", - description="Cycle Forward", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("cycle_backward")), - "noop", - description="Cycle Backward", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("prev_agent")), - "switch_prev_agent", - description="Previous Agent", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("next_agent")), - "switch_next_agent", - description="Next Agent", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("main_agent")), - "switch_to_primary", - description="Main Agent", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("panel_up")), - "scroll_panel_up", - description="Scroll Panels Up", - show=False, - ) - self.bind( - self._encode_keys(self.get_keys_for("panel_down")), - "scroll_panel_down", - description="Scroll Panels Down", - show=False, - ) - self.bind( - self._encode_keys(self.get_keys_for("cancel")), - "interrupt", - description="Cancel", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("editor")), - "open_editor", - description="Editor", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("history")), - "history_search", - description="History Search", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("focus")), - "focus_input", - description="Focus Input", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("stop")), - "interrupt", - description="Interrupt", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("clear")), - "clear_output", - description="Clear", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("quit")), "quit", description="Quit", show=True - ) - - self.bind( - self._encode_keys(self.get_keys_for("voice")), - "start_voice", - description="Record Voice", - show=True, - ) - self.bind( - self._encode_keys(self.get_keys_for("paste")), - "paste_clipboard", - description="Paste Clipboard", - show=True, + app_bindings = ( + # Input-context keys stay non-priority so InputArea can keep + # handling selection, completion, and submit behavior. + ("newline", "noop", "New Line", False, True), + ("submit", "noop", "Submit", False, True), + ("cycle_forward", "noop", "Cycle Forward", False, True), + ("cycle_backward", "noop", "Cycle Backward", False, True), + ("cancel", "interrupt", "Cancel", False, True), + ("stop", "interrupt", "Interrupt", False, True), + # These actions must work regardless of which widget has focus. + ("prev_agent", "switch_prev_agent", "Previous Agent", True, True), + ("next_agent", "switch_next_agent", "Next Agent", True, True), + ("main_agent", "switch_to_primary", "Main Agent", True, True), + ("panel_up", "scroll_panel_up", "Scroll Panels Up", True, False), + ("panel_down", "scroll_panel_down", "Scroll Panels Down", True, False), + ("editor", "open_editor", "Editor", True, True), + ("history", "history_search", "History Search", True, True), + ("focus", "focus_input", "Focus Input", True, True), + ("clear", "clear_output", "Clear", True, True), + ("quit", "quit", "Quit", True, True), + ("voice", "start_voice", "Record Voice", True, True), + ("paste", "paste_clipboard", "Paste Clipboard", True, True), + ("panels", "toggle_floating_panels", "Toggle Panels", True, False), ) - self.bind( - self._encode_keys(self.get_keys_for("panels")), - "toggle_floating_panels", - description="Toggle Panels", - show=False, - ) + for binding_type, action, description, priority, show in app_bindings: + self._bind_key( + binding_type, + action, + description=description, + priority=priority, + show=show, + ) self.register_theme(BASE_THEME) self.theme = "cecli" @@ -1774,10 +1699,28 @@ def _get_binding_keys(self, type): return keys if encoded == keys else f"{keys},{encoded}" + def _bind_key(self, type, action, description="", priority=True, show=True): + """Register a configured key binding on the app. + + Args: + type: The key binding name in ``tui_config``. + action: The Textual action to invoke. + description: Short description for the binding. + priority: Check the binding before the focused widget. + show: Whether the binding is shown in the footer. + """ + self._bindings.bind( + self._get_binding_keys(type), + action, + description=description, + show=show, + priority=priority, + ) + def _encode_keys(self, key): key = key.replace("shift+enter", "ctrl+j") - return key + return self._canonicalize_key_list(key) def _decode_keys(self, key): key = key.replace("ctrl+j", "shift+enter") @@ -1786,10 +1729,10 @@ def _decode_keys(self, key): def is_key_for(self, type, key): allowed_keys = self.tui_config["key_bindings"][type].split(",") - normalized_key = self._decode_keys(key) + normalized_key = self._encode_keys(key) for allowed_key in allowed_keys: - if normalized_key == self._decode_keys(allowed_key): + if normalized_key == self._encode_keys(allowed_key): return True return False @@ -1798,6 +1741,23 @@ def get_keys_for(self, type): allowed_keys = self.tui_config["key_bindings"][type] return self._decode_keys(allowed_keys) + def _canonicalize_key_list(self, keys): + """Return comma-separated keys in Textual's canonical modifier order.""" + return ",".join(self._canonicalize_key(key.strip()) for key in keys.split(",")) + + def _canonicalize_key(self, key): + """Return a key with modifiers ordered as Textual emits them.""" + if "+" not in key: + return key + + *modifiers, name = key.split("+") + if not modifiers or any(modifier not in MODIFIER_ORDER for modifier in modifiers): + return key + + modifiers = sorted(set(modifiers), key=MODIFIER_ORDER.index) + + return "+".join([*modifiers, name]) + def _do_quit(self): """Perform the actual quit after UI updates.""" self.worker.stop() diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index 52b92c33266..198dd70dd84 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -239,36 +239,6 @@ def on_key(self, event) -> None: if self.disabled: return - # Route hotkey bindings directly and stop TextArea from inserting a - # printable character (e.g. alt+shift+h would otherwise type an "h"). - if self.app.is_key_for("voice", event.key): - event.stop() - event.prevent_default() - self.app.action_start_voice() - return - - if self.app.is_key_for("paste", event.key): - event.stop() - event.prevent_default() - self.app.action_paste_clipboard() - - if self.app.is_key_for("panels", event.key): - event.stop() - event.prevent_default() - self.app.action_toggle_floating_panels() - return - - if self.app.is_key_for("history", event.key): - event.stop() - event.prevent_default() - self.app.action_history_search() - return - - # Reserve any other alt+shift combination for hotkeys (no character input). - if event.key and "alt+shift" in event.key: - event.stop() - event.prevent_default() - return # Reset cycling if not a cycle command is_cycle = self.app.is_key_for("cycle_forward", event.key) or self.app.is_key_for( "cycle_backward", event.key diff --git a/tests/tui/test_key_bindings.py b/tests/tui/test_key_bindings.py new file mode 100644 index 00000000000..9c741f037d4 --- /dev/null +++ b/tests/tui/test_key_bindings.py @@ -0,0 +1,55 @@ +"""Tests for TUI key binding normalization and registration.""" + +import pytest +from textual.binding import BindingsMap + +from cecli.tui.app import TUI + + +@pytest.fixture +def tui_instance(monkeypatch): + """Create a TUI instance without running Textual's App init.""" + monkeypatch.setattr("cecli.tui.app.TUI.__init__", lambda *args, **kwargs: None) + tui = TUI(coder_worker=None, output_queue=None, input_queue=None, args=None) + tui._bindings = BindingsMap() + tui.tui_config = {"key_bindings": {}} + return tui + + +def test_encode_keys_canonicalizes_modifier_order(tui_instance): + assert tui_instance._encode_keys("ctrl+alt+right") == "alt+ctrl+right" + assert tui_instance._encode_keys("shift+ctrl+alt+right") == "alt+ctrl+shift+right" + assert tui_instance._encode_keys("alt+ctrl+right") == "alt+ctrl+right" + + +def test_encode_keys_preserves_unknown_modifiers(tui_instance): + assert tui_instance._encode_keys("hyper+right") == "hyper+right" + + +def test_is_key_for_accepts_any_modifier_order(tui_instance): + tui_instance.tui_config["key_bindings"]["next_agent"] = tui_instance._encode_keys( + "ctrl+alt+right" + ) + assert tui_instance.is_key_for("next_agent", "alt+ctrl+right") + assert tui_instance.is_key_for("next_agent", "ctrl+alt+right") + assert not tui_instance.is_key_for("next_agent", "ctrl+alt+left") + + +def test_bind_key_registers_priority_binding(tui_instance): + tui_instance.tui_config["key_bindings"]["next_agent"] = tui_instance._encode_keys( + "ctrl+alt+right" + ) + tui_instance._bind_key( + "next_agent", "switch_next_agent", description="Next Agent", priority=True + ) + bindings = tui_instance._bindings.key_to_bindings["alt+ctrl+right"] + assert bindings + assert all(binding.priority for binding in bindings) + + +def test_bind_key_can_register_non_priority_binding(tui_instance): + tui_instance.tui_config["key_bindings"]["submit"] = tui_instance._encode_keys("enter") + tui_instance._bind_key("submit", "noop", description="Submit", priority=False) + bindings = tui_instance._bindings.key_to_bindings["enter"] + assert bindings + assert not any(binding.priority for binding in bindings) From 5f4ffc8a8870643cf2eba495eeb708e528adadf8 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 30 Oct 2026 19:01:00 -0400 Subject: [PATCH 06/13] Fix formatting --- cecli/coders/base_coder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 372a68f7ede..ed1cc94fd88 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2305,8 +2305,8 @@ async def compact_context_if_needed(self, force=False, message=""): # Condition 3: Absolute savings check (would compacting save enough tokens to fit?) tokens_over_limit = all_tokens - (self.context_compaction_max_tokens or all_tokens) potential_savings = compactable_tokens * 0.90 - is_worth_by_absolute_savings = ( - tokens_over_limit > 0 and potential_savings >= (tokens_over_limit * 0.50) + is_worth_by_absolute_savings = tokens_over_limit > 0 and potential_savings >= ( + tokens_over_limit * 0.50 ) if not force: From fd99cf63c9cdcd8ece4720cf4dec7d4ff2f95a5c Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 30 Oct 2026 22:37:25 -0400 Subject: [PATCH 07/13] Command consolidation part 1: /skills and /mcp --- cecli/commands/__init__.py | 33 +- cecli/commands/core.py | 22 +- cecli/commands/exclude_skill.py | 72 --- cecli/commands/import_skill.py | 95 ---- cecli/commands/include_skill.py | 72 --- cecli/commands/list_mcp.py | 71 --- cecli/commands/list_skills.py | 51 --- cecli/commands/load_mcp.py | 138 ------ cecli/commands/load_skill.py | 73 --- cecli/commands/mcp.py | 102 +++++ cecli/commands/mcp_helpers/__init__.py | 6 + cecli/commands/mcp_helpers/list.py | 67 +++ cecli/commands/mcp_helpers/load.py | 120 +++++ cecli/commands/mcp_helpers/remove.py | 102 +++++ cecli/commands/remove_mcp.py | 117 ----- cecli/commands/remove_skill.py | 72 --- cecli/commands/skills.py | 162 +++++++ cecli/commands/skills_helpers/__init__.py | 1 + cecli/commands/skills_helpers/exclude.py | 44 ++ cecli/commands/skills_helpers/import_skill.py | 64 +++ cecli/commands/skills_helpers/include.py | 44 ++ cecli/commands/skills_helpers/list.py | 20 + cecli/commands/skills_helpers/load.py | 45 ++ cecli/commands/skills_helpers/remove.py | 44 ++ cecli/commands/utils/base_command.py | 8 + cecli/commands/workflow_helpers/__init__.py | 1 + cecli/commands/workflow_helpers/common.py | 123 ++++++ cecli/commands/workflow_helpers/last.py | 40 ++ cecli/commands/workflow_helpers/list.py | 28 ++ cecli/commands/workflow_helpers/reload.py | 56 +++ cecli/commands/workflow_helpers/run.py | 52 +++ cecli/commands/workflow_helpers/show.py | 14 + cecli/commands/workflow_helpers/status.py | 49 ++ cecli/commands/workflow_helpers/toggle.py | 45 ++ cecli/commands/workflows.py | 417 +----------------- cecli/helpers/agents/service.py | 2 +- cecli/io.py | 19 +- cecli/tui/app.py | 5 +- cecli/tui/widgets/floating_panels.py | 34 +- cecli/tui/widgets/input_area.py | 22 + cecli/website/docs/config/skills.md | 16 +- cecli/website/docs/config/workflows.md | 6 +- cecli/website/docs/usage/commands.md | 4 +- cecli/website/docs/usage/modes.md | 2 +- tests/basic/test_io.py | 1 + tests/commands/test_mcp_skills_completions.py | 209 +++++++++ tests/commands/test_queue_commands.py | 2 +- tests/commands/test_workflows.py | 16 +- tests/helpers/workflows/test_runner.py | 4 +- tests/integration/test_workflows_e2e.py | 4 +- tests/mcp/test_manager_retry.py | 36 +- tests/mcp/test_server_name_normalization.py | 6 +- tests/subagents/test_service.py | 2 +- 53 files changed, 1599 insertions(+), 1261 deletions(-) delete mode 100644 cecli/commands/exclude_skill.py delete mode 100644 cecli/commands/import_skill.py delete mode 100644 cecli/commands/include_skill.py delete mode 100644 cecli/commands/list_mcp.py delete mode 100644 cecli/commands/list_skills.py delete mode 100644 cecli/commands/load_mcp.py delete mode 100644 cecli/commands/load_skill.py create mode 100644 cecli/commands/mcp.py create mode 100644 cecli/commands/mcp_helpers/__init__.py create mode 100644 cecli/commands/mcp_helpers/list.py create mode 100644 cecli/commands/mcp_helpers/load.py create mode 100644 cecli/commands/mcp_helpers/remove.py delete mode 100644 cecli/commands/remove_mcp.py delete mode 100644 cecli/commands/remove_skill.py create mode 100644 cecli/commands/skills.py create mode 100644 cecli/commands/skills_helpers/__init__.py create mode 100644 cecli/commands/skills_helpers/exclude.py create mode 100644 cecli/commands/skills_helpers/import_skill.py create mode 100644 cecli/commands/skills_helpers/include.py create mode 100644 cecli/commands/skills_helpers/list.py create mode 100644 cecli/commands/skills_helpers/load.py create mode 100644 cecli/commands/skills_helpers/remove.py create mode 100644 cecli/commands/workflow_helpers/__init__.py create mode 100644 cecli/commands/workflow_helpers/common.py create mode 100644 cecli/commands/workflow_helpers/last.py create mode 100644 cecli/commands/workflow_helpers/list.py create mode 100644 cecli/commands/workflow_helpers/reload.py create mode 100644 cecli/commands/workflow_helpers/run.py create mode 100644 cecli/commands/workflow_helpers/show.py create mode 100644 cecli/commands/workflow_helpers/status.py create mode 100644 cecli/commands/workflow_helpers/toggle.py create mode 100644 tests/commands/test_mcp_skills_completions.py diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 665c93ad2f9..ac10ac759bb 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -30,7 +30,6 @@ from .drop import DropCommand from .editor import EditCommand, EditorCommand from .editor_model import EditorModelCommand -from .exclude_skill import ExcludeSkillCommand from .exit import ExitCommand from .git import GitCommand from .hashline import HashlineCommand @@ -38,22 +37,17 @@ from .history_search import HistorySearchCommand from .hooks import HooksCommand from .hot_reload import HotReloadCommand -from .import_skill import ImportSkillCommand -from .include_skill import IncludeSkillCommand from .insert_queue import InsertQueueCommand from .lint import LintCommand -from .list_mcp import ListMcpCommand from .list_queue import ListQueueCommand from .list_sessions import ListSessionsCommand -from .list_skills import ListSkillsCommand from .load import LoadCommand from .load_hook import LoadHookCommand -from .load_mcp import LoadMcpCommand from .load_session import LoadSessionCommand -from .load_skill import LoadSkillCommand from .ls import LsCommand from .map import MapCommand from .map_refresh import MapRefreshCommand +from .mcp import McpCommand from .merge import MergeCommand from .model import ModelCommand from .models import ModelsCommand @@ -66,10 +60,8 @@ from .reap_agent import ReapAgentCommand from .reasoning_effort import ReasoningEffortCommand from .remove_hook import RemoveHookCommand -from .remove_mcp import RemoveMcpCommand from .remove_memory import RemoveMemoryCommand from .remove_queue import RemoveQueueCommand -from .remove_skill import RemoveSkillCommand from .report import ReportCommand from .reset import ResetCommand from .rules import RulesCommand @@ -79,6 +71,7 @@ from .save_session import SaveSessionCommand from .search_memory import SearchMemoryCommand from .settings import SettingsCommand +from .skills import SkillsCommand from .spawn_agent import SpawnAgentCommand from .switch_agent import SwitchAgentCommand from .terminal_setup import TerminalSetupCommand @@ -128,7 +121,6 @@ CommandRegistry.register(EditCommand) CommandRegistry.register(EditorCommand) CommandRegistry.register(EditorModelCommand) -CommandRegistry.register(ExcludeSkillCommand) CommandRegistry.register(ExitCommand) CommandRegistry.register(GitCommand) CommandRegistry.register(HashlineCommand) @@ -136,22 +128,17 @@ CommandRegistry.register(HistorySearchCommand) CommandRegistry.register(HooksCommand) CommandRegistry.register(HotReloadCommand) -CommandRegistry.register(ImportSkillCommand) -CommandRegistry.register(IncludeSkillCommand) CommandRegistry.register(InsertQueueCommand) CommandRegistry.register(LintCommand) -CommandRegistry.register(ListMcpCommand) CommandRegistry.register(ListQueueCommand) CommandRegistry.register(ListSessionsCommand) -CommandRegistry.register(ListSkillsCommand) CommandRegistry.register(LoadCommand) CommandRegistry.register(LoadHookCommand) -CommandRegistry.register(LoadMcpCommand) CommandRegistry.register(LoadSessionCommand) -CommandRegistry.register(LoadSkillCommand) CommandRegistry.register(LsCommand) CommandRegistry.register(MapCommand) CommandRegistry.register(MapRefreshCommand) +CommandRegistry.register(McpCommand) CommandRegistry.register(MergeCommand) CommandRegistry.register(ModelCommand) CommandRegistry.register(ModelsCommand) @@ -164,10 +151,8 @@ CommandRegistry.register(ReapAgentCommand) CommandRegistry.register(ReasoningEffortCommand) CommandRegistry.register(RemoveHookCommand) -CommandRegistry.register(RemoveMcpCommand) CommandRegistry.register(RemoveMemoryCommand) CommandRegistry.register(RemoveQueueCommand) -CommandRegistry.register(RemoveSkillCommand) CommandRegistry.register(ReportCommand) CommandRegistry.register(ResetCommand) CommandRegistry.register(RulesCommand) @@ -177,6 +162,7 @@ CommandRegistry.register(SaveSessionCommand) CommandRegistry.register(SearchMemoryCommand) CommandRegistry.register(SettingsCommand) +CommandRegistry.register(SkillsCommand) CommandRegistry.register(SpawnAgentCommand) CommandRegistry.register(SwitchAgentCommand) CommandRegistry.register(TerminalSetupCommand) @@ -219,7 +205,6 @@ "EditCommand", "EditorCommand", "EditorModelCommand", - "ExcludeSkillCommand", "ExitCommand", "expand_subdir", "format_command_result", @@ -231,22 +216,17 @@ "HistorySearchCommand", "HooksCommand", "HotReloadCommand", - "ImportSkillCommand", - "IncludeSkillCommand", "InsertQueueCommand", "LintCommand", - "ListMcpCommand", "ListQueueCommand", "ListSessionsCommand", - "ListSkillsCommand", "LoadCommand", "LoadHookCommand", - "LoadMcpCommand", "LoadSessionCommand", - "LoadSkillCommand", "LsCommand", "MapCommand", "MapRefreshCommand", + "McpCommand", "MergeCommand", "ModelCommand", "ModelsCommand", @@ -262,10 +242,8 @@ "ReasoningEffortCommand", "ReloadProgramSignal", "RemoveHookCommand", - "RemoveMcpCommand", "RemoveMemoryCommand", "RemoveQueueCommand", - "RemoveSkillCommand", "ReportCommand", "ResetCommand", "RulesCommand", @@ -275,6 +253,7 @@ "SaveSessionCommand", "SearchMemoryCommand", "SettingsCommand", + "SkillsCommand", "SpawnAgentCommand", "SwitchAgentCommand", "SwitchCoderSignal", diff --git a/cecli/commands/core.py b/cecli/commands/core.py index bb9063f5223..acbd9dde093 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -16,7 +16,7 @@ class Commands: # Commands that receive their full argument text for completion (nested # hosts and subcommand commands whose names take an argument). - FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue", "/workflows") + FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue", "/workflows", "/mcp", "/skills") def _get_coder(self): """Return coder via weak reference, or None if collected.""" @@ -174,6 +174,26 @@ def get_completions(self, cmd, args="", coder=None): return command_class.get_completions(self.io, coder or self.coder, args) return [] + def has_subcommands(self, cmd): + """Return True when *cmd* declares subcommands.""" + cmd = cmd[1:] if cmd.startswith("/") else cmd + command_class = CommandRegistry.get_command(cmd) + return bool(getattr(command_class, "SUBCOMMANDS", ())) + + def at_subcommand_boundary(self, text): + """Return True when *text* ends after a command or first subcommand token.""" + if not text.endswith(" "): + return False + + words = text.split() + if not words or not words[0].startswith("/"): + return False + + if not self.has_subcommands(words[0]): + return False + + return len(words) <= 2 + def get_commands(self): registry_commands = CommandRegistry.list_commands() commands = [f"/{cmd}" for cmd in registry_commands] diff --git a/cecli/commands/exclude_skill.py b/cecli/commands/exclude_skill.py deleted file mode 100644 index a859004b2b4..00000000000 --- a/cecli/commands/exclude_skill.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class ExcludeSkillCommand(BaseCommand): - NORM_NAME = "exclude-skill" - DESCRIPTION = "Exclude a skill by name (agent mode only)" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the exclude-skill command with given parameters.""" - if not args.strip(): - io.tool_output("Usage: /exclude-skill ") - return format_command_result(io, "exclude-skill", "Usage: /exclude-skill ") - - skill_names = args.strip().split() - - # Check if we're in agent mode - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - io.tool_output("Skill exclusion is only available in agent mode.") - return format_command_result( - io, "exclude-skill", "Skill exclusion is only available in agent mode" - ) - - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - io.tool_output("Skills manager is not initialized. Skills may not be configured.") - # Check if skills directories are configured - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - io.tool_output( - "No skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return format_command_result(io, "exclude-skill", "Skills manager is not initialized") - - results = [] - for skill_name in skill_names: - # Use the instance method on skills_manager - result = coder.skills_manager.exclude_skill(skill_name) - results.append(result) - - return format_command_result(io, "exclude-skill", "\n".join(results)) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for exclude-skill command.""" - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - return [] - - try: - skills = coder.skills_manager.find_skills() - return [skill.name for skill in skills] - except Exception: - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the exclude-skill command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /exclude-skill ... # Exclude one or more skills by name\n" - help_text += "\nExamples:\n" - help_text += " /exclude-skill pdf # Exclude (blacklist) the PDF skill\n" - help_text += " /exclude-skill web pdf # Exclude both web and PDF skills\n" - help_text += ( - "\nThis command excludes one or more skills by name, adding them to the blacklist. " - "Skills are only available in agent mode.\n" - ) - help_text += "Excluded skills will be hidden from discovery and unavailable for loading.\n" - return help_text diff --git a/cecli/commands/import_skill.py b/cecli/commands/import_skill.py deleted file mode 100644 index 93bfdcacd04..00000000000 --- a/cecli/commands/import_skill.py +++ /dev/null @@ -1,95 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class ImportSkillCommand(BaseCommand): - NORM_NAME = "import-skill" - DESCRIPTION = "Import a skill from the community registry or skills.sh (agent mode only)" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the import-skill command with given parameters.""" - tokens = args.strip().split() - global_install = "--global" in tokens or "-g" in tokens - tokens = [token for token in tokens if token not in ("--global", "-g")] - - if not tokens: - io.tool_output("Usage: /import-skill [--global] ") - return format_command_result( - io, "import-skill", "Usage: /import-skill [--global] " - ) - - skill_name = " ".join(tokens) - - # Importing (and including) skills is only available in agent mode. - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - io.tool_output("Skill import is only available in agent mode.") - return format_command_result( - io, "import-skill", "Skill import is only available in agent mode" - ) - - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - io.tool_output("Skills manager is not initialized. Skills may not be configured.") - return format_command_result(io, "import-skill", "Skills manager is not initialized") - - from cecli.helpers.extensions.skills_importer import ( - add_skill_to_config, - install_skill, - ) - - root = getattr(coder, "primary_root", None) or getattr(coder, "root", None) - result = install_skill(skill_name, global_install=global_install, root=root) - - if not result["ok"]: - io.tool_output(result["message"]) - return format_command_result(io, "import-skill", result["message"]) - - imported_name = result["name"] - include_result = coder.skills_manager.include_skill(imported_name) - config_result = add_skill_to_config(imported_name, root=root) - - message = ( - f"Imported skill '{imported_name}' from {result['source']} to {result['dest']}.\n\n" - f"{include_result}\n\n" - f"{config_result}" - ) - - return format_command_result(io, "import-skill", message) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for import-skill command.""" - candidates = ["--global"] - - try: - from cecli.helpers.extensions.skills_importer import get_registry_skills - - candidates.extend(get_registry_skills()) - except Exception: - pass - - return candidates - - @classmethod - def get_help(cls) -> str: - """Get help text for the import-skill command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += ( - " /import-skill # Import a skill into the project .cecli/skills\n" - ) - help_text += ( - " /import-skill --global # Import a skill into ~/.cecli/skills\n" - ) - help_text += "\nExamples:\n" - help_text += ( - " /import-skill files/docx # Import the docx skill from the community registry\n" - ) - help_text += " /import-skill --global pdf # Import the PDF skill globally\n" - help_text += ( - "\nSkills are looked up in the cecli community registry first, then on skills.sh.\n" - ) - help_text += "The imported skill is added to the current session like /include-skill.\n" - return help_text diff --git a/cecli/commands/include_skill.py b/cecli/commands/include_skill.py deleted file mode 100644 index 181cf1aafc3..00000000000 --- a/cecli/commands/include_skill.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class IncludeSkillCommand(BaseCommand): - NORM_NAME = "include-skill" - DESCRIPTION = "Include a skill by name (agent mode only)" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the include-skill command with given parameters.""" - if not args.strip(): - io.tool_output("Usage: /include-skill ") - return format_command_result(io, "include-skill", "Usage: /include-skill ") - - skill_names = args.strip().split() - - # Check if we're in agent mode - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - io.tool_output("Skill inclusion is only available in agent mode.") - return format_command_result( - io, "include-skill", "Skill inclusion is only available in agent mode" - ) - - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - io.tool_output("Skills manager is not initialized. Skills may not be configured.") - # Check if skills directories are configured - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - io.tool_output( - "No skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return format_command_result(io, "include-skill", "Skills manager is not initialized") - - results = [] - for skill_name in skill_names: - # Use the instance method on skills_manager - result = coder.skills_manager.include_skill(skill_name) - results.append(result) - - return format_command_result(io, "include-skill", "\n".join(results)) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for include-skill command.""" - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - return [] - - try: - skills = coder.skills_manager.find_skills() - return [skill.name for skill in skills] - except Exception: - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the include-skill command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /include-skill ... # Include one or more skills by name\n" - help_text += "\nExamples:\n" - help_text += " /include-skill pdf # Include (whitelist) the PDF skill\n" - help_text += " /include-skill web pdf # Include both web and PDF skills\n" - help_text += ( - "\nThis command includes one or more skills by name, adding them to the whitelist. " - "Skills are only available in agent mode.\n" - ) - help_text += "When a skill is included, only whitelisted skills will be discoverable.\n" - return help_text diff --git a/cecli/commands/list_mcp.py b/cecli/commands/list_mcp.py deleted file mode 100644 index 0cf14c56446..00000000000 --- a/cecli/commands/list_mcp.py +++ /dev/null @@ -1,71 +0,0 @@ -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class ListMcpCommand(BaseCommand): - NORM_NAME = "list-mcp" - DESCRIPTION = "List all loaded and configured MCP servers." - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the list-mcp command.""" - if not coder.mcp_manager: - return format_command_result(io, cls.NORM_NAME, "MCP manager is not configured.") - - all_servers = coder.mcp_manager.servers - connected_servers = coder.mcp_manager.connected_servers - - connected_server_names = {s.name for s in connected_servers} - - # Apply per-coder registered_servers filtering to determine active status - incl = coder.registered_servers["included"] - excl = coder.registered_servers["excluded"] - - active_servers = [] - inactive_servers = [] - - for server in connected_servers: - name = server.name - # Same filtering logic used in base_coder.get_tool_list() - if incl and name not in incl: - inactive_servers.append(name) - elif name in excl: - inactive_servers.append(name) - else: - active_servers.append(name) - - configured_servers = [ - server for server in all_servers if server.name not in connected_server_names - ] - - result = [] - if active_servers: - result.append("Active MCP Servers:") - for name in sorted(active_servers): - result.append(f"- {name}") - else: - result.append("No MCP servers are active for this coder.") - - if inactive_servers: - result.append("") - result.append("Inactive (Filtered) MCP Servers:") - for name in sorted(inactive_servers): - result.append(f"- {name}") - - result.append("") - if configured_servers: - result.append("Configured MCP Servers:") - for server in sorted(configured_servers, key=lambda s: s.name): - result.append(f"- {server.name}") - else: - result.append("No other MCP servers are configured.") - - return format_command_result(io, cls.NORM_NAME, "\n".join(result)) - - @classmethod - def get_help(cls) -> str: - """Get help text for the list-mcp command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /list-mcp # Lists MCP servers with coder-sensitive active/inactive/configured status\n" - return help_text diff --git a/cecli/commands/list_skills.py b/cecli/commands/list_skills.py deleted file mode 100644 index 752dc885c06..00000000000 --- a/cecli/commands/list_skills.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class ListSkillsCommand(BaseCommand): - NORM_NAME = "list-skills" - DESCRIPTION = "List all available skills with their states and file paths" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the list-skills command with given parameters.""" - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - io.tool_output("Skills manager is not initialized. Skills may not be configured.") - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - io.tool_output( - "No skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return format_command_result(io, "list-skills", "Skills manager is not initialized") - - try: - formatted = coder.skills_manager.get_skills_list_formatted() - return format_command_result(io, "list-skills", formatted) - except Exception as e: - error_msg = f"Error listing skills: {e}" - return format_command_result(io, "list-skills", error_msg) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for list-skills command.""" - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the list-skills command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /list-skills # List all available skills with their states and paths\n" - help_text += "\nExamples:\n" - help_text += ( - " /list-skills # Shows a table of all skills, their include/exclude/visible status,\n" - ) - help_text += " # whether they are loaded, and their directory paths\n" - help_text += "\n" - help_text += "This command lists all skills found in the configured skill directories,\n" - help_text += "displaying their current status (included/excluded/visible),\n" - help_text += "whether they are loaded into context, and their file system paths.\n" - return help_text diff --git a/cecli/commands/load_mcp.py b/cecli/commands/load_mcp.py deleted file mode 100644 index 686682d5ae6..00000000000 --- a/cecli/commands/load_mcp.py +++ /dev/null @@ -1,138 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import ( - format_command_result, - iter_all_coders, - update_server_registration, -) - - -class LoadMcpCommand(BaseCommand): - NORM_NAME = "load-mcp" - DESCRIPTION = "Load MCP server(s) by name, or use '*' to load all enabled servers" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the load-mcp command with given parameters.""" - if not args.strip(): - return format_command_result(io, cls.NORM_NAME, "Usage: /load-mcp ") - - if not coder.mcp_manager or not coder.mcp_manager.servers: - return format_command_result( - io, cls.NORM_NAME, "No MCP servers found, nothing to load." - ) - - server_names = args.strip().split() - - results = [] - - servers_to_load = [] - - # Handle '*' wildcard to load all servers enabled by default - if server_names == ["*"]: - for server in coder.mcp_manager.servers: - if server in coder.mcp_manager.connected_servers: - results.append(f"Server already loaded: {server.name}") - continue - - auto_connect = server.config.get("enabled", True) - if not auto_connect: - results.append(f"Skipping server (not enabled by default): {server.name}") - continue - - servers_to_load.append(server) - else: - for server_name in server_names: - server = coder.mcp_manager.get_server(server_name) - if server is None: - io.tool_error(f"MCP server {server_name} does not exist.") - results.append(f"MCP server {server_name} does not exist.") - else: - servers_to_load.append(server) - - # Early exit if nothing valid to process - if not servers_to_load and results: - return format_command_result(io, cls.NORM_NAME, "", "\n".join(results)) - - # Before connecting any new server, convert coders with empty included sets - # to explicit include lists of all currently connected MCP servers. - # This moves them from "implicitly include all" to explicit state-machine - # management, preventing the new server from being implicitly available - # to all coders. - connected_names = {s.name.lower() for s in coder.mcp_manager.connected_servers} - if connected_names: - for c in iter_all_coders(coder): - if not c.registered_servers["included"]: - included = set(connected_names) - c.registered_servers["excluded"] - if c.edit_format in ("agent", "subagent"): - included.add("local") # "local" is always available - c.registered_servers["included"] = included - - # Process connections with interrupt support - for server in servers_to_load: - server_name = server.name - coder.interrupt_event.clear() - - did_connect, interrupted = await coder.coroutines.interruptible( - coder.mcp_manager.connect_server(server_name), - coder.interrupt_event, - ) - - if interrupted: - io.tool_warning(f"MCP connection interrupted: {server_name}") - results.append(f"Interrupted: {server_name}") - continue - - if did_connect: - # Force-include on the primary (active) coder - update_server_registration(coder, server_name, "include", force=True) - - # Safe-exclude on all other coders (respects existing inclusions) - for other_coder in iter_all_coders(coder): - if other_coder is coder: - continue - update_server_registration(other_coder, server_name, "exclude", force=False) - results.append(f"Loaded server: {server_name}") - else: - results.append(f"Unable to load server: {server_name}") - - io.tool_output("\n".join(results)) - - from . import SwitchCoderSignal - - raise SwitchCoderSignal( - edit_format=coder.edit_format, - summarize_from_coder=False, - from_coder=coder, - show_announcements=True, - ) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for load-mcp command.""" - if not coder.mcp_manager or not coder.mcp_manager.servers: - return [] - - try: - server_names = [ - server.name - for server in coder.mcp_manager - if server not in coder.mcp_manager.connected_servers - and server.name != "unnamed-server" - ] - return server_names - except Exception: - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the load-mcp command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /load-mcp ... # Load one or more mcps by name\n" - help_text += " /load-mcp * # Load all mcps enabled by default\n" - help_text += "\nExamples:\n" - help_text += " /load-mcp context7 # Load the context7 mcp\n" - help_text += " /load-mcp github context7 # Load both github and context7 mcps\n" - help_text += " /load-mcp * # Load all mcps enabled by default\n" diff --git a/cecli/commands/load_skill.py b/cecli/commands/load_skill.py deleted file mode 100644 index 1d18afe023f..00000000000 --- a/cecli/commands/load_skill.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class LoadSkillCommand(BaseCommand): - NORM_NAME = "load-skill" - DESCRIPTION = "Load a skill by name (agent mode only)" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the load-skill command with given parameters.""" - if not args.strip(): - io.tool_output("Usage: /load-skill ") - return format_command_result(io, "load-skill", "Usage: /load-skill ") - - skill_names = args.strip().split() - - # Check if we're in agent mode - io.tool_output(coder.edit_format) - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - io.tool_output("Skill loading is only available in agent mode.") - return format_command_result( - io, "load-skill", "Skill loading is only available in agent mode" - ) - - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - io.tool_output("Skills manager is not initialized. Skills may not be configured.") - # Check if skills directories are configured - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - io.tool_output( - "No skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return format_command_result(io, "load-skill", "Skills manager is not initialized") - - results = [] - for skill_name in skill_names: - # Use the instance method on skills_manager - result = coder.skills_manager.load_skill(skill_name) - results.append(result) - - return format_command_result(io, "load-skill", "\n".join(results)) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for load-skill command.""" - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - return [] - - try: - skills = coder.skills_manager.find_skills() - return [skill.name for skill in skills] - except Exception: - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the load-skill command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /load-skill ... # Load one or more skills by name\n" - help_text += "\nExamples:\n" - help_text += " /load-skill pdf # Load the PDF skill\n" - help_text += " /load-skill web pdf # Load both web and PDF skills\n" - help_text += ( - "\nThis command loads one or more skills by name. Skills are only available in agent" - " mode.\n" - ) - help_text += "Skills provide additional functionality and tools to the agent.\n" - return help_text diff --git a/cecli/commands/mcp.py b/cecli/commands/mcp.py new file mode 100644 index 00000000000..a3b2ef7270b --- /dev/null +++ b/cecli/commands/mcp.py @@ -0,0 +1,102 @@ +"""/mcp — manage MCP servers (list, load, remove). + +Consolidates the former ``/list-mcp``, ``/load-mcp`` and ``/remove-mcp`` +commands; the per-subcommand logic lives in ``cecli.commands.mcp_helpers``. +""" + +from cecli.commands.utils.base_command import BaseCommand + + +class McpCommand(BaseCommand): + NORM_NAME = "mcp" + DESCRIPTION = "Manage MCP servers (list, load, remove)" + + SUBCOMMANDS = ("list", "load", "remove") + NAME_SUBCOMMANDS = ("load", "remove") + HELPER_MODULES = { + "list": "cecli.commands.mcp_helpers.list", + "load": "cecli.commands.mcp_helpers.load", + "remove": "cecli.commands.mcp_helpers.remove", + } + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Dispatch an mcp subcommand. + + Syntax: + /mcp list — List MCP servers (default) + /mcp load ... — Load one or more mcps by name + /mcp load * — Load all mcps enabled by default + /mcp remove ... — Remove one or more mcps by name + /mcp remove * — Remove all connected mcps + """ + parts = (args or "").strip().split(maxsplit=1) + sub = parts[0].lower() if parts else "list" + rest = parts[1].strip() if len(parts) > 1 else "" + + if sub == "list": + return await cls.loader("list").cmd_list(io, coder) + + if sub == "load": + return await cls.loader("load").cmd_load(io, coder, rest) + + if sub == "remove": + return await cls.loader("remove").cmd_remove(io, coder, rest) + + io.tool_error(f"Unknown subcommand '{sub}'. Try one of: {', '.join(cls.SUBCOMMANDS)}") + + @classmethod + def get_help(cls) -> str: + """Get help text for the mcp command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += ( + " /mcp list # Lists MCP servers with coder-sensitive" + " active/inactive/configured status\n" + ) + help_text += " /mcp load ... # Load one or more mcps by name\n" + help_text += " /mcp load * # Load all mcps enabled by default\n" + help_text += " /mcp remove ... # Remove one or more mcps by name\n" + help_text += " /mcp remove * # Remove all connected mcps\n" + help_text += "\nExamples:\n" + help_text += " /mcp load context7 # Load the context7 mcp\n" + help_text += " /mcp load github context7 # Load both github and context7 mcps\n" + help_text += " /mcp remove context7 # Remove the context7 mcp\n" + help_text += " /mcp remove github context7 # Remove both github and context7 mcps\n" + help_text += " /mcp remove * # Remove all connected mcps\n" + return help_text + + @classmethod + def get_completions(cls, io, coder, args) -> list: + """Complete the subcommand, then each argument in turn. + + The whole argument string is used so a space moves completion on to + the next argument (e.g. ``load context7 `` offers more servers). + """ + raw = args or "" + head = raw.lstrip() + + if not head: + return list(cls.SUBCOMMANDS) + + if " " not in head: + return [s for s in cls.SUBCOMMANDS if s.startswith(head.lower())] or list( + cls.SUBCOMMANDS + ) + + sub, _, rest = head.partition(" ") + sub = sub.lower() + if sub not in cls.SUBCOMMANDS: + return [] + + if sub not in cls.NAME_SUBCOMMANDS: + return [] + + return cls._name_completions(io, coder, sub, rest) + + @classmethod + def _name_completions(cls, io, coder, sub, rest): + """Filter the subcommand's server names by the argument being typed.""" + current = "" if not rest or rest.endswith(" ") else rest.rsplit(" ", 1)[-1] + names = cls.loader(sub).get_completions(io, coder) + return [name for name in names if name.startswith(current)] diff --git a/cecli/commands/mcp_helpers/__init__.py b/cecli/commands/mcp_helpers/__init__.py new file mode 100644 index 00000000000..c4ea17d2880 --- /dev/null +++ b/cecli/commands/mcp_helpers/__init__.py @@ -0,0 +1,6 @@ +"""/mcp subcommand implementations. + +The ``/list-mcp``, ``/load-mcp`` and ``/remove-mcp`` commands were consolidated +into ``/mcp ``; these modules hold the per-subcommand logic so the +command class stays a thin dispatcher. +""" diff --git a/cecli/commands/mcp_helpers/list.py b/cecli/commands/mcp_helpers/list.py new file mode 100644 index 00000000000..859b1e805c9 --- /dev/null +++ b/cecli/commands/mcp_helpers/list.py @@ -0,0 +1,67 @@ +"""Implementation of ``/mcp list``.""" + +from cecli.commands.utils.helpers import format_command_result + +# Legacy command name, kept so the returned status string does not change. +RESULT_NAME = "list-mcp" + + +async def cmd_list(io, coder): + """List all loaded and configured MCP servers.""" + if not coder.mcp_manager: + return format_command_result(io, RESULT_NAME, "MCP manager is not configured.") + + all_servers = coder.mcp_manager.servers + connected_servers = coder.mcp_manager.connected_servers + + connected_server_names = {s.name for s in connected_servers} + + # Apply per-coder registered_servers filtering to determine active status + incl = coder.registered_servers["included"] + excl = coder.registered_servers["excluded"] + + active_servers = [] + inactive_servers = [] + + for server in connected_servers: + name = server.name + # Same filtering logic used in base_coder.get_tool_list() + if incl and name not in incl: + inactive_servers.append(name) + elif name in excl: + inactive_servers.append(name) + else: + active_servers.append(name) + + configured_servers = [ + server for server in all_servers if server.name not in connected_server_names + ] + + result = [] + if active_servers: + result.append("Active MCP Servers:") + for name in sorted(active_servers): + result.append(f"- {name}") + else: + result.append("No MCP servers are active for this coder.") + + if inactive_servers: + result.append("") + result.append("Inactive (Filtered) MCP Servers:") + for name in sorted(inactive_servers): + result.append(f"- {name}") + + result.append("") + if configured_servers: + result.append("Configured MCP Servers:") + for server in sorted(configured_servers, key=lambda s: s.name): + result.append(f"- {server.name}") + else: + result.append("No other MCP servers are configured.") + + return format_command_result(io, RESULT_NAME, "\n".join(result)) + + +def get_completions(io, coder): + """``/mcp list`` takes no arguments, so there is nothing to complete.""" + return [] diff --git a/cecli/commands/mcp_helpers/load.py b/cecli/commands/mcp_helpers/load.py new file mode 100644 index 00000000000..daa5ef89433 --- /dev/null +++ b/cecli/commands/mcp_helpers/load.py @@ -0,0 +1,120 @@ +"""Implementation of ``/mcp load``.""" + +from cecli.commands.utils.helpers import ( + format_command_result, + iter_all_coders, + update_server_registration, +) + +# Legacy command name, kept so the returned status string does not change. +RESULT_NAME = "load-mcp" + + +async def cmd_load(io, coder, args): + """Load MCP server(s) by name, or use '*' to load all enabled servers.""" + if not args.strip(): + return format_command_result(io, RESULT_NAME, "Usage: /mcp load ") + + if not coder.mcp_manager or not coder.mcp_manager.servers: + return format_command_result(io, RESULT_NAME, "No MCP servers found, nothing to load.") + + server_names = args.strip().split() + + results = [] + + servers_to_load = [] + + # Handle '*' wildcard to load all servers enabled by default + if server_names == ["*"]: + for server in coder.mcp_manager.servers: + if server in coder.mcp_manager.connected_servers: + results.append(f"Server already loaded: {server.name}") + continue + + auto_connect = server.config.get("enabled", True) + if not auto_connect: + results.append(f"Skipping server (not enabled by default): {server.name}") + continue + + servers_to_load.append(server) + else: + for server_name in server_names: + server = coder.mcp_manager.get_server(server_name) + if server is None: + io.tool_error(f"MCP server {server_name} does not exist.") + results.append(f"MCP server {server_name} does not exist.") + else: + servers_to_load.append(server) + + # Early exit if nothing valid to process + if not servers_to_load and results: + return format_command_result(io, RESULT_NAME, "", "\n".join(results)) + + # Before connecting any new server, convert coders with empty included sets + # to explicit include lists of all currently connected MCP servers. + # This moves them from "implicitly include all" to explicit state-machine + # management, preventing the new server from being implicitly available + # to all coders. + connected_names = {s.name.lower() for s in coder.mcp_manager.connected_servers} + if connected_names: + for c in iter_all_coders(coder): + if not c.registered_servers["included"]: + included = set(connected_names) - c.registered_servers["excluded"] + if c.edit_format in ("agent", "subagent"): + included.add("local") # "local" is always available + c.registered_servers["included"] = included + + # Process connections with interrupt support + for server in servers_to_load: + server_name = server.name + coder.interrupt_event.clear() + + did_connect, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.connect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + io.tool_warning(f"MCP connection interrupted: {server_name}") + results.append(f"Interrupted: {server_name}") + continue + + if did_connect: + # Force-include on the primary (active) coder + update_server_registration(coder, server_name, "include", force=True) + + # Safe-exclude on all other coders (respects existing inclusions) + for other_coder in iter_all_coders(coder): + if other_coder is coder: + continue + update_server_registration(other_coder, server_name, "exclude", force=False) + results.append(f"Loaded server: {server_name}") + else: + results.append(f"Unable to load server: {server_name}") + + io.tool_output("\n".join(results)) + + from cecli.commands import SwitchCoderSignal + + raise SwitchCoderSignal( + edit_format=coder.edit_format, + summarize_from_coder=False, + from_coder=coder, + show_announcements=True, + ) + + +def get_completions(io, coder): + """Completion options for load: servers that are not connected yet.""" + if not coder.mcp_manager or not coder.mcp_manager.servers: + return [] + + try: + server_names = [ + server.name + for server in coder.mcp_manager + if server not in coder.mcp_manager.connected_servers and server.name != "unnamed-server" + ] + return server_names + except Exception: + return [] diff --git a/cecli/commands/mcp_helpers/remove.py b/cecli/commands/mcp_helpers/remove.py new file mode 100644 index 00000000000..591c19b75b2 --- /dev/null +++ b/cecli/commands/mcp_helpers/remove.py @@ -0,0 +1,102 @@ +"""Implementation of ``/mcp remove``.""" + +from cecli.commands.utils.helpers import ( + format_command_result, + is_server_globally_excluded, + update_server_registration, +) + +# Legacy command name, kept so the returned status string does not change. +RESULT_NAME = "remove-mcp" + + +async def cmd_remove(io, coder, args): + """Remove (unload) MCP server(s) by name, or use '*' to remove all.""" + if not args.strip(): + return format_command_result(io, RESULT_NAME, "Usage: /mcp remove ") + + if not coder.mcp_manager or not coder.mcp_manager.servers: + return format_command_result( + io, RESULT_NAME, "No MCP servers connected, nothing to remove." + ) + + server_names = args.strip().split() + + results = [] + servers_to_disconnect = [] + + # Handle '*' wildcard to disconnect all servers + if server_names == ["*"]: + connected = [s for s in coder.mcp_manager.servers if s.is_connected] + + if not connected: + results.append("No MCP servers connected, nothing to remove.") + else: + servers_to_disconnect.extend(connected) + else: + for server_name in server_names: + servers_to_disconnect.append(server_name) + + # Early exit if nothing to process + if not servers_to_disconnect and results: + return format_command_result(io, RESULT_NAME, "", "\n".join(results)) + + # Process disconnections with interrupt support + for item in servers_to_disconnect: + server_name = item.name if hasattr(item, "name") else item + + # Never remove the "local" server + if server_name.lower() == "local": + results.append("Cannot remove 'Local' server") + continue + + # Force-exclude on the primary (active) coder + update_server_registration(coder, server_name, "exclude", force=True) + # Check if all coders in the hierarchy have this server excluded + all_excluded = is_server_globally_excluded(coder, server_name) + + if all_excluded: + was_disconnected, interrupted = await coder.coroutines.interruptible( + coder.mcp_manager.disconnect_server(server_name), + coder.interrupt_event, + ) + + if interrupted: + io.tool_warning(f"MCP disconnection interrupted: {server_name}") + results.append(f"Interrupted: {server_name}") + continue + + if was_disconnected: + results.append(f"Removed server: {server_name}") + else: + results.append(f"Unable to remove server: {server_name}") + else: + io.tool_output( + f"Server '{server_name}' still in use by other coders, " + f"keeping connection active." + ) + results.append(f"Removed from active coder, still active for others: {server_name}") + + io.tool_output("\n".join(results)) + + from cecli.commands import SwitchCoderSignal + + raise SwitchCoderSignal( + edit_format=coder.edit_format, + summarize_from_coder=False, + from_coder=coder, + show_announcements=True, + mcp_manager=coder.mcp_manager, + ) + + +def get_completions(io, coder): + """Completion options for remove: currently connected servers.""" + if not coder.mcp_manager or not coder.mcp_manager.servers: + return [] + + try: + server_names = [server.name for server in coder.mcp_manager if server.is_connected] + return server_names + except Exception: + return [] diff --git a/cecli/commands/remove_mcp.py b/cecli/commands/remove_mcp.py deleted file mode 100644 index 05f23de8f11..00000000000 --- a/cecli/commands/remove_mcp.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import ( - format_command_result, - is_server_globally_excluded, - update_server_registration, -) - - -class RemoveMcpCommand(BaseCommand): - NORM_NAME = "remove-mcp" - DESCRIPTION = "Remove (unload) a MCP server by name, or use '*' to remove all" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the remove-mcp command with given parameters.""" - if not args.strip(): - return format_command_result(io, cls.NORM_NAME, "Usage: /remove-mcp ") - - if not coder.mcp_manager or not coder.mcp_manager.servers: - return format_command_result( - io, cls.NORM_NAME, "No MCP servers connected, nothing to remove." - ) - - server_names = args.strip().split() - - results = [] - servers_to_disconnect = [] - - # Handle '*' wildcard to disconnect all servers - if server_names == ["*"]: - connected = [s for s in coder.mcp_manager.servers if s.is_connected] - - if not connected: - results.append("No MCP servers connected, nothing to remove.") - else: - servers_to_disconnect.extend(connected) - else: - for server_name in server_names: - servers_to_disconnect.append(server_name) - - # Early exit if nothing to process - if not servers_to_disconnect and results: - return format_command_result(io, cls.NORM_NAME, "", "\n".join(results)) - - # Process disconnections with interrupt support - for item in servers_to_disconnect: - server_name = item.name if hasattr(item, "name") else item - - # Never remove the "local" server - if server_name.lower() == "local": - results.append("Cannot remove 'Local' server") - continue - - # Force-exclude on the primary (active) coder - update_server_registration(coder, server_name, "exclude", force=True) - # Check if all coders in the hierarchy have this server excluded - all_excluded = is_server_globally_excluded(coder, server_name) - - if all_excluded: - was_disconnected, interrupted = await coder.coroutines.interruptible( - coder.mcp_manager.disconnect_server(server_name), - coder.interrupt_event, - ) - - if interrupted: - io.tool_warning(f"MCP disconnection interrupted: {server_name}") - results.append(f"Interrupted: {server_name}") - continue - - if was_disconnected: - results.append(f"Removed server: {server_name}") - else: - results.append(f"Unable to remove server: {server_name}") - else: - io.tool_output( - f"Server '{server_name}' still in use by other coders, " - f"keeping connection active." - ) - results.append(f"Removed from active coder, still active for others: {server_name}") - - io.tool_output("\n".join(results)) - - from . import SwitchCoderSignal - - raise SwitchCoderSignal( - edit_format=coder.edit_format, - summarize_from_coder=False, - from_coder=coder, - show_announcements=True, - mcp_manager=coder.mcp_manager, - ) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for remove-mcp command.""" - if not coder.mcp_manager or not coder.mcp_manager.servers: - return [] - - try: - server_names = [server.name for server in coder.mcp_manager if server.is_connected] - return server_names - except Exception: - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the remove-mcp command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /remove-mcp ... # Remove one or more mcps by name\n" - help_text += " /remove-mcp * # Remove all connected mcps\n" - help_text += "\nExamples:\n" - help_text += " /remove-mcp context7 # Remove the context7 mcp\n" - help_text += " /remove-mcp github context7 # Remove both github and context7 mcps\n" - help_text += " /remove-mcp * # Remove all connected mcps\n" diff --git a/cecli/commands/remove_skill.py b/cecli/commands/remove_skill.py deleted file mode 100644 index e557abeeeae..00000000000 --- a/cecli/commands/remove_skill.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class RemoveSkillCommand(BaseCommand): - NORM_NAME = "remove-skill" - DESCRIPTION = "Remove a skill by name (agent mode only)" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the remove-skill command with given parameters.""" - if not args.strip(): - io.tool_output("Usage: /remove-skill ") - return format_command_result(io, "remove-skill", "Usage: /remove-skill ") - - skill_names = args.strip().split() - - # Check if we're in agent mode - if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): - io.tool_output("Skill removal is only available in agent mode.") - return format_command_result( - io, "remove-skill", "Skill removal is only available in agent mode" - ) - - # Check if skills_manager is available - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - io.tool_output("Skills manager is not initialized. Skills may not be configured.") - # Check if skills directories are configured - if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: - io.tool_output( - "No skills directories configured. Use --skills-paths to configure skill" - " directories." - ) - return format_command_result(io, "remove-skill", "Skills manager is not initialized") - - results = [] - for skill_name in skill_names: - # Use the instance method on skills_manager - result = coder.skills_manager.remove_skill(skill_name) - results.append(result) - - return format_command_result(io, "remove-skill", "\n".join(results)) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for remove-skill command.""" - if not hasattr(coder, "skills_manager") or coder.skills_manager is None: - return [] - - try: - skills = coder.skills_manager.find_skills() - return [skill.name for skill in skills] - except Exception: - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the remove-skill command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /remove-skill ... # Remove one or more skills by name\n" - help_text += "\nExamples:\n" - help_text += " /remove-skill pdf # Remove the PDF skill\n" - help_text += " /remove-skill web pdf # Remove both web and PDF skills\n" - help_text += ( - "\nThis command removes one or more skills by name. Skills are only available in agent" - " mode.\n" - ) - help_text += "Skills provide additional functionality and tools to the agent.\n" - return help_text diff --git a/cecli/commands/skills.py b/cecli/commands/skills.py new file mode 100644 index 00000000000..30dc5456b52 --- /dev/null +++ b/cecli/commands/skills.py @@ -0,0 +1,162 @@ +"""/skills — manage skills (list, load, include, exclude, remove, import). + +Subcommands are dispatched to helper functions in ``cecli.commands.skills_helpers``. +""" + +from typing import List + +from cecli.commands.utils.base_command import BaseCommand + + +class SkillsCommand(BaseCommand): + NORM_NAME = "skills" + DESCRIPTION = "Manage skills (list, load, include, exclude, remove, import)" + + SUBCOMMANDS = ("list", "load", "include", "exclude", "remove", "import") + NAME_SUBCOMMANDS = ("load", "include", "exclude", "remove") + HELPER_MODULES = { + "list": "cecli.commands.skills_helpers.list", + "load": "cecli.commands.skills_helpers.load", + "include": "cecli.commands.skills_helpers.include", + "exclude": "cecli.commands.skills_helpers.exclude", + "remove": "cecli.commands.skills_helpers.remove", + "import": "cecli.commands.skills_helpers.import_skill", + } + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Dispatch a skills subcommand. + + Syntax: + /skills list — Show all skills and their states + /skills load ... — Load one or more skills (agent mode) + /skills include ...— Whitelist one or more skills (agent mode) + /skills exclude ...— Blacklist one or more skills (agent mode) + /skills remove ... — Remove one or more skills (agent mode) + /skills import [--global] — Import a skill from the registry + """ + parts = (args or "").strip().split(maxsplit=1) + sub = parts[0].lower() if parts else "list" + rest = parts[1].strip() if len(parts) > 1 else "" + + if sub == "list": + return await cls.loader("list").cmd_list(io, coder) + elif sub == "load": + return await cls.loader("load").cmd_load(io, coder, rest) + elif sub == "include": + return await cls.loader("include").cmd_include(io, coder, rest) + elif sub == "exclude": + return await cls.loader("exclude").cmd_exclude(io, coder, rest) + elif sub == "remove": + return await cls.loader("remove").cmd_remove(io, coder, rest) + elif sub == "import": + return await cls.loader("import").cmd_import(io, coder, rest) + + io.tool_error(f"Unknown subcommand '{sub}'. Try one of: {', '.join(cls.SUBCOMMANDS)}") + + @classmethod + def get_help(cls) -> str: + """Get help text for the skills command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /skills list # List skills and their paths\n" + help_text += " /skills load ... # Load one or more skills\n" + help_text += " /skills include ... # Whitelist one or more skills\n" + help_text += " /skills exclude ... # Blacklist one or more skills\n" + help_text += " /skills remove ... # Remove one or more skills\n" + help_text += " /skills import [--global] # Import a skill\n" + help_text += "\nExamples:\n" + help_text += ( + " /skills list # Shows a table of all skills, their include/exclude/visible status,\n" + ) + help_text += " # whether they are loaded, and their directory paths\n" + help_text += " /skills load pdf # Load the PDF skill\n" + help_text += " /skills load web pdf # Load both web and PDF skills\n" + help_text += " /skills include pdf # Include (whitelist) the PDF skill\n" + help_text += " /skills include web pdf # Include both web and PDF skills\n" + help_text += " /skills exclude pdf # Exclude (blacklist) the PDF skill\n" + help_text += " /skills exclude web pdf # Exclude both web and PDF skills\n" + help_text += " /skills remove pdf # Remove the PDF skill\n" + help_text += " /skills remove web pdf # Remove both web and PDF skills\n" + help_text += ( + " /skills import files/docx # Import the docx skill from the community registry\n" + ) + help_text += " /skills import --global pdf # Import the PDF skill globally\n" + help_text += "\n" + help_text += ( + "Skills are only available in agent mode. /skills list shows all skills found in the\n" + ) + help_text += "configured skill directories, displaying their current status\n" + help_text += ( + "(included/excluded/visible), whether they are loaded into context, and their\n" + ) + help_text += "file system paths.\n" + help_text += ( + "Loading pulls a skill and its tools into the agent's context, while including\n" + ) + help_text += ( + "adds it to the whitelist: when a skill is included, only whitelisted skills will\n" + ) + help_text += ( + "be discoverable. Excluded skills are hidden from discovery and unavailable for\n" + ) + help_text += "loading.\n" + help_text += "\n" + help_text += ( + "Skills are looked up in the cecli community registry first, then on skills.sh.\n" + ) + help_text += "The imported skill is added to the current session like /skills include.\n" + return help_text + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Complete the subcommand, then each argument in turn. + + The whole argument string is used so a space moves completion on to + the next argument (e.g. ``include pdf `` offers more skills). + """ + raw = args or "" + head = raw.lstrip() + + if not head: + return list(cls.SUBCOMMANDS) + + if " " not in head: + return [s for s in cls.SUBCOMMANDS if s.startswith(head.lower())] or list( + cls.SUBCOMMANDS + ) + + sub, _, rest = head.partition(" ") + sub = sub.lower() + if sub not in cls.SUBCOMMANDS: + return [] + + if sub == "import": + return cls._import_completions(io, coder, rest) + + if sub not in cls.NAME_SUBCOMMANDS: + return [] + + return cls._name_completions(io, coder, sub, rest) + + @classmethod + def _name_completions(cls, io, coder, sub, rest) -> List[str]: + """Filter the subcommand's skill names by the argument being typed.""" + current = "" if not rest or rest.endswith(" ") else rest.rsplit(" ", 1)[-1] + names = cls.loader(sub).get_completions(io, coder) + return [name for name in names if name.startswith(current)] + + @classmethod + def _import_completions(cls, io, coder, rest) -> List[str]: + """Complete ``import`` flags first, then registry skill names.""" + if not rest or rest.endswith(" "): + completed, current = rest.split(), "" + else: + head, _, current = rest.rpartition(" ") + completed = head.split() + + candidates = cls.loader("import").get_completions(io, coder) + if any(token in ("--global", "-g") for token in completed): + candidates = [c for c in candidates if c not in ("--global", "-g")] + + return [c for c in candidates if c.startswith(current)] diff --git a/cecli/commands/skills_helpers/__init__.py b/cecli/commands/skills_helpers/__init__.py new file mode 100644 index 00000000000..a507bb8400d --- /dev/null +++ b/cecli/commands/skills_helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers implementing the subcommands of the ``/skills`` command.""" diff --git a/cecli/commands/skills_helpers/exclude.py b/cecli/commands/skills_helpers/exclude.py new file mode 100644 index 00000000000..065f142153c --- /dev/null +++ b/cecli/commands/skills_helpers/exclude.py @@ -0,0 +1,44 @@ +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_exclude(io, coder, args): + """Exclude one or more skills by name, adding them to the blacklist.""" + if not args.strip(): + io.tool_output("Usage: /skills exclude ") + return format_command_result(io, "exclude-skill", "Usage: /skills exclude ") + + skill_names = args.strip().split() + + if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): + io.tool_output("Skill exclusion is only available in agent mode.") + return format_command_result( + io, "exclude-skill", "Skill exclusion is only available in agent mode" + ) + + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: + io.tool_output( + "No skills directories configured. Use --skills-paths to configure skill" + " directories." + ) + return format_command_result(io, "exclude-skill", "Skills manager is not initialized") + + results = [] + for skill_name in skill_names: + result = coder.skills_manager.exclude_skill(skill_name) + results.append(result) + + return format_command_result(io, "exclude-skill", "\n".join(results)) + + +def get_completions(io, coder): + """Get completion options for the exclude subcommand.""" + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + return [] + + try: + skills = coder.skills_manager.find_skills() + return [skill.name for skill in skills] + except Exception: + return [] diff --git a/cecli/commands/skills_helpers/import_skill.py b/cecli/commands/skills_helpers/import_skill.py new file mode 100644 index 00000000000..4fe0789d590 --- /dev/null +++ b/cecli/commands/skills_helpers/import_skill.py @@ -0,0 +1,64 @@ +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_import(io, coder, args): + """Import a skill from the community registry or skills.sh.""" + tokens = args.strip().split() + global_install = "--global" in tokens or "-g" in tokens + tokens = [token for token in tokens if token not in ("--global", "-g")] + + if not tokens: + io.tool_output("Usage: /skills import [--global] ") + return format_command_result( + io, "import-skill", "Usage: /skills import [--global] " + ) + + skill_name = " ".join(tokens) + + if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): + io.tool_output("Skill import is only available in agent mode.") + return format_command_result( + io, "import-skill", "Skill import is only available in agent mode" + ) + + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + return format_command_result(io, "import-skill", "Skills manager is not initialized") + + from cecli.helpers.extensions.skills_importer import ( + add_skill_to_config, + install_skill, + ) + + root = getattr(coder, "primary_root", None) or getattr(coder, "root", None) + result = install_skill(skill_name, global_install=global_install, root=root) + + if not result["ok"]: + io.tool_output(result["message"]) + return format_command_result(io, "import-skill", result["message"]) + + imported_name = result["name"] + include_result = coder.skills_manager.include_skill(imported_name) + config_result = add_skill_to_config(imported_name, root=root) + + message = ( + f"Imported skill '{imported_name}' from {result['source']} to {result['dest']}.\n\n" + f"{include_result}\n\n" + f"{config_result}" + ) + + return format_command_result(io, "import-skill", message) + + +def get_completions(io, coder): + """Get completion options for the import subcommand.""" + candidates = ["--global"] + + try: + from cecli.helpers.extensions.skills_importer import get_registry_skills + + candidates.extend(get_registry_skills()) + except Exception: + pass + + return candidates diff --git a/cecli/commands/skills_helpers/include.py b/cecli/commands/skills_helpers/include.py new file mode 100644 index 00000000000..c06427e7811 --- /dev/null +++ b/cecli/commands/skills_helpers/include.py @@ -0,0 +1,44 @@ +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_include(io, coder, args): + """Include one or more skills by name, adding them to the whitelist.""" + if not args.strip(): + io.tool_output("Usage: /skills include ") + return format_command_result(io, "include-skill", "Usage: /skills include ") + + skill_names = args.strip().split() + + if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): + io.tool_output("Skill inclusion is only available in agent mode.") + return format_command_result( + io, "include-skill", "Skill inclusion is only available in agent mode" + ) + + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: + io.tool_output( + "No skills directories configured. Use --skills-paths to configure skill" + " directories." + ) + return format_command_result(io, "include-skill", "Skills manager is not initialized") + + results = [] + for skill_name in skill_names: + result = coder.skills_manager.include_skill(skill_name) + results.append(result) + + return format_command_result(io, "include-skill", "\n".join(results)) + + +def get_completions(io, coder): + """Get completion options for the include subcommand.""" + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + return [] + + try: + skills = coder.skills_manager.find_skills() + return [skill.name for skill in skills] + except Exception: + return [] diff --git a/cecli/commands/skills_helpers/list.py b/cecli/commands/skills_helpers/list.py new file mode 100644 index 00000000000..46c5d9b1ad7 --- /dev/null +++ b/cecli/commands/skills_helpers/list.py @@ -0,0 +1,20 @@ +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_list(io, coder): + """List all available skills with their states and file paths.""" + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: + io.tool_output( + "No skills directories configured. Use --skills-paths to configure skill" + " directories." + ) + return format_command_result(io, "list-skills", "Skills manager is not initialized") + + try: + formatted = coder.skills_manager.get_skills_list_formatted() + return format_command_result(io, "list-skills", formatted) + except Exception as e: + error_msg = f"Error listing skills: {e}" + return format_command_result(io, "list-skills", error_msg) diff --git a/cecli/commands/skills_helpers/load.py b/cecli/commands/skills_helpers/load.py new file mode 100644 index 00000000000..195e4fee209 --- /dev/null +++ b/cecli/commands/skills_helpers/load.py @@ -0,0 +1,45 @@ +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_load(io, coder, args): + """Load one or more skills by name.""" + if not args.strip(): + io.tool_output("Usage: /skills load ") + return format_command_result(io, "load-skill", "Usage: /skills load ") + + skill_names = args.strip().split() + + io.tool_output(coder.edit_format) + if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): + io.tool_output("Skill loading is only available in agent mode.") + return format_command_result( + io, "load-skill", "Skill loading is only available in agent mode" + ) + + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: + io.tool_output( + "No skills directories configured. Use --skills-paths to configure skill" + " directories." + ) + return format_command_result(io, "load-skill", "Skills manager is not initialized") + + results = [] + for skill_name in skill_names: + result = coder.skills_manager.load_skill(skill_name) + results.append(result) + + return format_command_result(io, "load-skill", "\n".join(results)) + + +def get_completions(io, coder): + """Get completion options for the load subcommand.""" + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + return [] + + try: + skills = coder.skills_manager.find_skills() + return [skill.name for skill in skills] + except Exception: + return [] diff --git a/cecli/commands/skills_helpers/remove.py b/cecli/commands/skills_helpers/remove.py new file mode 100644 index 00000000000..139f4c385a6 --- /dev/null +++ b/cecli/commands/skills_helpers/remove.py @@ -0,0 +1,44 @@ +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_remove(io, coder, args): + """Remove one or more skills by name.""" + if not args.strip(): + io.tool_output("Usage: /skills remove ") + return format_command_result(io, "remove-skill", "Usage: /skills remove ") + + skill_names = args.strip().split() + + if not hasattr(coder, "edit_format") or coder.edit_format not in ("agent", "subagent"): + io.tool_output("Skill removal is only available in agent mode.") + return format_command_result( + io, "remove-skill", "Skill removal is only available in agent mode" + ) + + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + io.tool_output("Skills manager is not initialized. Skills may not be configured.") + if hasattr(coder, "skills_directory_paths") and not coder.skills_directory_paths: + io.tool_output( + "No skills directories configured. Use --skills-paths to configure skill" + " directories." + ) + return format_command_result(io, "remove-skill", "Skills manager is not initialized") + + results = [] + for skill_name in skill_names: + result = coder.skills_manager.remove_skill(skill_name) + results.append(result) + + return format_command_result(io, "remove-skill", "\n".join(results)) + + +def get_completions(io, coder): + """Get completion options for the remove subcommand.""" + if not hasattr(coder, "skills_manager") or coder.skills_manager is None: + return [] + + try: + skills = coder.skills_manager.find_skills() + return [skill.name for skill in skills] + except Exception: + return [] diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index db6006234a5..8716b210cb2 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -37,6 +37,7 @@ class BaseCommand(ABC, metaclass=CommandMeta): NORM_NAME = None # Normalized command name (e.g., "add", "model") DESCRIPTION = None # Command description for help SCHEMA = None # Optional schema for parameter validation + HELPER_MODULES = {} # subcommand -> helper module path (optional) show_completion_notification = False @classmethod @@ -178,3 +179,10 @@ async def _generic_chat_command(cls, io, coder, args, edit_format, placeholder=N main_model=original_main_model, edit_format=original_edit_format, ) + + @classmethod + def loader(cls, sub): + """Import the helper module registered for a subcommand on first use.""" + from importlib import import_module + + return import_module(cls.HELPER_MODULES[sub]) diff --git a/cecli/commands/workflow_helpers/__init__.py b/cecli/commands/workflow_helpers/__init__.py new file mode 100644 index 00000000000..0666391a9c2 --- /dev/null +++ b/cecli/commands/workflow_helpers/__init__.py @@ -0,0 +1 @@ +"""Subcommand implementations behind the thin ``/workflows`` dispatcher.""" diff --git a/cecli/commands/workflow_helpers/common.py b/cecli/commands/workflow_helpers/common.py new file mode 100644 index 00000000000..0de994a9bf5 --- /dev/null +++ b/cecli/commands/workflow_helpers/common.py @@ -0,0 +1,123 @@ +"""Shared lookups used by the ``/workflows`` subcommand modules.""" + +import logging + +logger = logging.getLogger(__name__) + + +def workflows(coder) -> list: + """The loaded WorkflowConfigs: coder-provided first, else the register cache.""" + loaded = getattr(coder, "workflows", None) + if isinstance(loaded, (list, tuple)): + return list(loaded) + + from cecli.helpers.workflows.subagents import get_workflow_configs + + return get_workflow_configs() + + +def find(coder, name): + """Look up a workflow by name, ignoring a leading ``wf:`` prefix.""" + if not name: + return None + + bare = name[3:] if name.startswith("wf:") else name + for wf in workflows(coder): + if wf.name == bare: + return wf + + return None + + +def require(io, coder, name, sub): + """Resolve a required workflow-name argument or emit the error.""" + if not name: + io.tool_error(f"Usage: /workflows {sub} ") + return None + + wf = find(coder, name) + if wf is None: + io.tool_error(unknown_message(coder, name)) + + return wf + + +def unknown_message(coder, name): + known = ", ".join(sorted(wf.name for wf in workflows(coder))) or "none loaded" + return f"Error: unknown workflow '{name}'. Available: {known}" + + +def scheduler_db(coder): + scheduler = getattr(coder, "scheduler", None) + return getattr(scheduler, "db", None) + + +async def job_rows(coder, name=None): + """jobs-table rows keyed by name (empty dict when no scheduler DB).""" + db = scheduler_db(coder) + if db is None: + return {} + + list_jobs = getattr(db, "list_jobs", None) + if list_jobs is None: + return {} + + try: + rows = await list_jobs() + except Exception: + logger.warning("scheduler list_jobs failed", exc_info=True) + return {} + + jobs = {} + for row in rows or []: + row = dict(row) + key = row.get("name") + if key and (name is None or key == name): + jobs[key] = row + + return jobs + + +async def active_leases(coder, name=None): + """Currently-held leases, if the scheduler DB exposes them.""" + db = scheduler_db(coder) + method = getattr(db, "active_leases", None) if db is not None else None + if method is None: + return [] + + try: + rows = await (method(name) if name else method()) + except TypeError: + try: + rows = await method() + except Exception: + logger.warning("scheduler active_leases failed", exc_info=True) + return [] + except Exception: + logger.warning("scheduler active_leases failed", exc_info=True) + return [] + + rows = [dict(r) for r in rows or []] + return [r for r in rows if name is None or r.get("name") == name] + + +async def recent_run(coder, name): + """The newest runs-table row for *name*, if a scheduler DB is present.""" + db = scheduler_db(coder) + recent_runs = getattr(db, "recent_runs", None) if db is not None else None + if recent_runs is None: + return None + + try: + rows = await recent_runs(name, limit=1) + except TypeError: + try: + rows = await recent_runs(name) + except Exception: + logger.warning("scheduler recent_runs failed", exc_info=True) + return None + except Exception: + logger.warning("scheduler recent_runs failed", exc_info=True) + return None + + return dict(rows[0]) if rows else None diff --git a/cecli/commands/workflow_helpers/last.py b/cecli/commands/workflow_helpers/last.py new file mode 100644 index 00000000000..f443a880117 --- /dev/null +++ b/cecli/commands/workflow_helpers/last.py @@ -0,0 +1,40 @@ +"""``/workflows last`` — summarize the most recent run of a workflow.""" + +from cecli.commands.workflow_helpers.common import find, recent_run + + +async def cmd_last(io, coder, name) -> None: + """Summarize the most recent run of a workflow (DB row, else run log).""" + from cecli.helpers.workflows import logs + + if not name: + io.tool_error("Usage: /workflows last ") + return + + wf = find(coder, name) + lookup = wf.name if wf else name.removeprefix("wf:") + + row = await recent_run(coder, lookup) + if row is not None: + io.print(f"Last run of '{lookup}' ({row.get('finished_at') or row.get('started_at')}):") + io.print(f" Status: {row.get('status')}") + io.print(f" Source: {row.get('source')}") + if row.get("summary"): + io.print(f" Summary: {row.get('summary')}") + return + + path = logs.latest_log_path(lookup) + if path is None: + io.tool_output(f"No recorded runs for '{lookup}'.") + return + + lines = logs.read_log_lines(path) + finish = next((ln for ln in reversed(lines) if ln.get("event") == "finish"), {}) + io.print(f"Last run of '{lookup}' (log: {path}):") + io.print(f" Status: {finish.get('status', '?')}") + io.print(f" Source: {finish.get('source', '?')}") + if finish.get("summary"): + io.print(f" Summary: {finish.get('summary')}") + for ln in lines: + if ln.get("event") == "task": + io.print(f" task[{ln.get('idx')}] {ln.get('label')}: {ln.get('status')}") diff --git a/cecli/commands/workflow_helpers/list.py b/cecli/commands/workflow_helpers/list.py new file mode 100644 index 00000000000..8c765ac5dd6 --- /dev/null +++ b/cecli/commands/workflow_helpers/list.py @@ -0,0 +1,28 @@ +"""``/workflows list`` — show every loaded workflow.""" + +from cecli.commands.workflow_helpers.common import job_rows, workflows + + +async def cmd_list(io, coder) -> None: + """Print each loaded workflow with its schedule and (if known) last run.""" + loaded = workflows(coder) + if not loaded: + io.tool_output("No workflows are loaded.") + io.tool_output( + "Add YAML files to ~/.cecli/workflows/ or a 'workflows:' config" + " section, then run /workflows reload." + ) + return + + jobs = await job_rows(coder) + + io.print("Workflows:") + for wf in sorted(loaded, key=lambda w: w.name): + job = jobs.get(wf.name) or {} + state = "enabled" if wf.enabled else "disabled" + line = f" - {wf.name} [{state}] schedule: {wf.schedule or 'manual'}" + if job: + line += f" | last: {job.get('last_run') or '-'}" f" ({job.get('last_status') or '-'})" + io.print(line) + if wf.description: + io.print(f" {wf.description.strip()}") diff --git a/cecli/commands/workflow_helpers/reload.py b/cecli/commands/workflow_helpers/reload.py new file mode 100644 index 00000000000..3f2e246ff5a --- /dev/null +++ b/cecli/commands/workflow_helpers/reload.py @@ -0,0 +1,56 @@ +"""``/workflows reload`` — re-read workflow definitions.""" + + +def cmd_reload(io, coder) -> None: + """Re-read workflow definitions and refresh the wf: agent registrations.""" + try: + from cecli.helpers.workflows import load_workflows + + workflows = load_workflows( + config_arg=getattr(coder, "workflows_config_arg", None), + merged_config=getattr(coder, "workflows_merged_config", None), + git_root=getattr(coder, "root", None), + ) + except Exception as exc: + io.tool_error(f"Error reloading workflows: {exc}") + return + + from cecli.helpers.workflows.subagents import ( + get_workflow_configs, + register_workflow_subagents, + unregister_workflow_subagents, + ) + + # Files re-read here may not cover sources the startup load had access + # to (inline --workflows / merged config); keep those cached configs so + # they don't silently vanish from the registry on reload. + previous = get_workflow_configs() + by_name = {wf.name: wf for wf in previous} + by_name.update({wf.name: wf for wf in workflows}) + merged = sorted(by_name.values(), key=lambda w: w.name) + + unregister_workflow_subagents(previous) + registered = register_workflow_subagents(merged) + + # A running scheduler must learn the new definitions too (hash change + # reseeds next_run; vanished names get disabled) - fire-and-forget. + scheduler = getattr(coder, "scheduler", None) + if scheduler is not None and getattr(scheduler, "db", None) is not None: + import asyncio + + try: + asyncio.get_running_loop().create_task( + scheduler.db.sync_definitions({wf.name: wf for wf in merged if wf.enabled}) + ) + except RuntimeError: + pass + + try: + coder.workflows = merged + except Exception: + pass + + io.tool_output( + f"Reloaded {len(merged)} workflow(s);" + f" {len(registered)} agent(s) registered: {', '.join(registered) or 'none'}" + ) diff --git a/cecli/commands/workflow_helpers/run.py b/cecli/commands/workflow_helpers/run.py new file mode 100644 index 00000000000..c73fc9fe1f2 --- /dev/null +++ b/cecli/commands/workflow_helpers/run.py @@ -0,0 +1,52 @@ +"""``/workflows run`` — start a manual run in the background.""" + +import asyncio + +from cecli.commands.workflow_helpers.common import require + +# Strong refs to in-flight background ``/workflows run`` tasks (the event loop +# only weakly references tasks, so an unreferenced one can be GC'd mid-run). +_bg_runs: set = set() + + +async def cmd_run(io, coder, name) -> None: + """Start a background manual run of the named workflow.""" + from cecli.helpers.workflows.runner import execute_workflow + + wf = require(io, coder, name, "run") + if wf is None: + return + + task = asyncio.create_task(execute_workflow(coder, wf, source="manual")) + + # The loop keeps only weak references to tasks; hold one here so a + # background run can never be garbage-collected mid-flight, and report + # the summary into the conversation when it finishes. + _bg_runs.add(task) + task.add_done_callback(lambda t: report_run(io, t, wf.name)) + io.tool_output(f"workflow '{wf.name}' started in the background") + + +def report_run(io, task, name) -> None: + """Done-callback for background manual runs: release ref + announce.""" + _bg_runs.discard(task) + + try: + result = task.result() + except asyncio.CancelledError: + io.tool_warning(f"workflow '{name}' run was cancelled") + return + except Exception as exc: + io.tool_error(f"workflow '{name}' run failed: {exc}") + return + + line = f"workflow '{name}' finished: {result.status}" + summary = (result.summary or "").strip().replace("\n", " ") + + if summary: + line += f" \u2014 {summary[:240]}" + + if result.status == "ok": + io.tool_output(line) + else: + io.tool_error(line) diff --git a/cecli/commands/workflow_helpers/show.py b/cecli/commands/workflow_helpers/show.py new file mode 100644 index 00000000000..d4383f43d70 --- /dev/null +++ b/cecli/commands/workflow_helpers/show.py @@ -0,0 +1,14 @@ +"""``/workflows show`` — dump a workflow definition.""" + +from cecli.commands.workflow_helpers.common import require + + +def cmd_show(io, coder, name) -> None: + """Dump the workflow definition as YAML.""" + import yaml + + wf = require(io, coder, name, "show") + if wf is None: + return + + io.tool_output(yaml.dump(wf.to_dict(), sort_keys=False, default_flow_style=False)) diff --git a/cecli/commands/workflow_helpers/status.py b/cecli/commands/workflow_helpers/status.py new file mode 100644 index 00000000000..8c475ee069a --- /dev/null +++ b/cecli/commands/workflow_helpers/status.py @@ -0,0 +1,49 @@ +"""``/workflows status`` — scheduler job state, or the config view.""" + +from cecli.commands.workflow_helpers.common import ( + active_leases, + find, + job_rows, + unknown_message, +) +from cecli.commands.workflow_helpers.list import cmd_list +from cecli.commands.workflow_helpers.show import cmd_show + + +async def cmd_status(io, coder, name) -> None: + """Show scheduler job rows and active leases, or fall back to the list view.""" + if name and not find(coder, name): + io.tool_error(unknown_message(coder, name)) + return + + jobs = await job_rows(coder, name=name or None) + leases = await active_leases(coder, name=name or None) + + if not jobs and not leases: + # No scheduler DB state visible — degrade to the config view. + if name: + cmd_show(io, coder, name) + else: + await cmd_list(io, coder) + io.tool_output("(scheduler not running — showing config view)") + return + + if jobs: + io.print("Scheduler jobs:") + for job_name, row in sorted(jobs.items()): + io.print( + f" - {job_name}: enabled={row.get('enabled')}" + f" engine={row.get('engine') or '-'}" + f" next_run={row.get('next_run') or '-'}" + f" last_run={row.get('last_run') or '-'}" + f" ({row.get('last_status') or '-'})" + ) + + if leases: + io.print("Active leases:") + for lease in leases: + io.print( + f" - {lease.get('name')}: slot={lease.get('slot')}" + f" instance={lease.get('instance_id')}" + f" expires={lease.get('lease_expires_at')}" + ) diff --git a/cecli/commands/workflow_helpers/toggle.py b/cecli/commands/workflow_helpers/toggle.py new file mode 100644 index 00000000000..ab1d3798c9b --- /dev/null +++ b/cecli/commands/workflow_helpers/toggle.py @@ -0,0 +1,45 @@ +"""``/workflows enable|disable`` — flip a workflow for this session.""" + +import logging + +from cecli.commands.workflow_helpers.common import require, scheduler_db + +logger = logging.getLogger(__name__) + + +async def cmd_toggle(io, coder, name, enable) -> None: + """Flip wf.enabled for the session and (un)register the wf: agent.""" + from cecli.helpers.workflows.subagents import ( + register_workflow_subagents, + unregister_workflow_subagents, + ) + + wf = require(io, coder, name, "enable" if enable else "disable") + if wf is None: + return + + wf.enabled = bool(enable) + if enable: + register_workflow_subagents([wf]) + else: + unregister_workflow_subagents([wf]) + + db = scheduler_db(coder) + if db is not None: + method = getattr(db, "enable" if enable else "disable", None) + if method is not None: + try: + await (method(wf.name) if enable else method(wf.name, "disabled via /workflows")) + except TypeError: + try: + await method(wf.name) + except Exception: + logger.warning("scheduler db sync failed", exc_info=True) + except Exception: + logger.warning("scheduler db sync failed", exc_info=True) + + state = "enabled" if enable else "disabled" + io.tool_output( + f"Workflow '{wf.name}' {state} for this session (files unchanged —" + " /workflows reload re-reads them)." + ) diff --git a/cecli/commands/workflows.py b/cecli/commands/workflows.py index e35bf795ed8..c1dd548b45d 100644 --- a/cecli/commands/workflows.py +++ b/cecli/commands/workflows.py @@ -5,17 +5,8 @@ is never blocked by a workflow. """ -import asyncio -import logging - from cecli.commands.utils.base_command import BaseCommand -logger = logging.getLogger(__name__) - -# Strong refs to in-flight background ``/workflows run`` tasks (the event loop -# only weakly references tasks, so an unreferenced one can be GC'd mid-run). -_bg_runs: set = set() - class WorkflowCommand(BaseCommand): NORM_NAME = "workflows" @@ -24,6 +15,15 @@ class WorkflowCommand(BaseCommand): SUBCOMMANDS = ("list", "run", "show", "status", "last", "enable", "disable", "reload") NAME_SUBCOMMANDS = ("run", "show", "status", "last", "enable", "disable") + HELPER_MODULES = { + "list": "cecli.commands.workflow_helpers.list", + "run": "cecli.commands.workflow_helpers.run", + "show": "cecli.commands.workflow_helpers.show", + "status": "cecli.commands.workflow_helpers.status", + "last": "cecli.commands.workflow_helpers.last", + "toggle": "cecli.commands.workflow_helpers.toggle", + "reload": "cecli.commands.workflow_helpers.reload", + } @classmethod async def execute(cls, io, coder, args, **kwargs): @@ -44,19 +44,19 @@ async def execute(cls, io, coder, args, **kwargs): rest = parts[1].strip() if len(parts) > 1 else "" if sub == "list": - await cls._cmd_list(io, coder) + await cls.loader("list").cmd_list(io, coder) elif sub == "run": - await cls._cmd_run(io, coder, rest) + await cls.loader("run").cmd_run(io, coder, rest) elif sub == "show": - cls._cmd_show(io, coder, rest) + cls.loader("show").cmd_show(io, coder, rest) elif sub == "status": - await cls._cmd_status(io, coder, rest) + await cls.loader("status").cmd_status(io, coder, rest) elif sub == "last": - await cls._cmd_last(io, coder, rest) + await cls.loader("last").cmd_last(io, coder, rest) elif sub in ("enable", "disable"): - await cls._cmd_toggle(io, coder, rest, enable=(sub == "enable")) + await cls.loader("toggle").cmd_toggle(io, coder, rest, enable=(sub == "enable")) elif sub == "reload": - cls._cmd_reload(io, coder) + cls.loader("reload").cmd_reload(io, coder) else: io.tool_error(f"Unknown subcommand '{sub}'. Try one of: {', '.join(cls.SUBCOMMANDS)}") @@ -78,6 +78,8 @@ def get_help(cls) -> str: @classmethod def get_completions(cls, io, coder, args) -> list: """Complete subcommands, then workflow names for the name-taking ones.""" + from cecli.commands.workflow_helpers.common import workflows + raw = args or "" partial = raw.strip() @@ -87,7 +89,7 @@ def get_completions(cls, io, coder, args) -> list: # (when the subcommand takes one) instead of the subcommand list. if raw and raw[-1].isspace(): if partial.lower() in cls.NAME_SUBCOMMANDS: - return sorted(wf.name for wf in cls._workflows(coder)) + return sorted(wf.name for wf in workflows(coder)) return [] @@ -95,386 +97,7 @@ def get_completions(cls, io, coder, args) -> list: sub, _, rest = partial.partition(" ") if sub.lower() in cls.NAME_SUBCOMMANDS: - names = sorted(wf.name for wf in cls._workflows(coder)) + names = sorted(wf.name for wf in workflows(coder)) return [n for n in names if n.startswith(rest)] return [] - - # ------------------------------------------------------------------ # - # Subcommands - # ------------------------------------------------------------------ # - - @classmethod - async def _cmd_list(cls, io, coder) -> None: - """Print each loaded workflow with its schedule and (if known) last run.""" - workflows = cls._workflows(coder) - if not workflows: - io.tool_output("No workflows are loaded.") - io.tool_output( - "Add YAML files to ~/.cecli/workflows/ or a 'workflows:' config" - " section, then run /workflows reload." - ) - return - - jobs = await cls._job_rows(coder) - - io.print("Workflows:") - for wf in sorted(workflows, key=lambda w: w.name): - job = jobs.get(wf.name) or {} - state = "enabled" if wf.enabled else "disabled" - line = f" - {wf.name} [{state}] schedule: {wf.schedule or 'manual'}" - if job: - line += ( - f" | last: {job.get('last_run') or '-'}" f" ({job.get('last_status') or '-'})" - ) - io.print(line) - if wf.description: - io.print(f" {wf.description.strip()}") - - @classmethod - async def _cmd_run(cls, io, coder, name) -> None: - """Start a background manual run of the named workflow.""" - from cecli.helpers.workflows.runner import execute_workflow - - wf = cls._require(io, coder, name, "run") - if wf is None: - return - - task = asyncio.create_task(execute_workflow(coder, wf, source="manual")) - - # The loop keeps only weak references to tasks; hold one here so a - # background run can never be garbage-collected mid-flight, and report - # the summary into the conversation when it finishes. - _bg_runs.add(task) - task.add_done_callback(lambda t: cls._report_run(io, t, wf.name)) - io.tool_output(f"workflow '{wf.name}' started in the background") - - @classmethod - def _report_run(cls, io, task, name) -> None: - """Done-callback for background manual runs: release ref + announce.""" - _bg_runs.discard(task) - - try: - result = task.result() - except asyncio.CancelledError: - io.tool_warning(f"workflow '{name}' run was cancelled") - - return - except Exception as exc: - io.tool_error(f"workflow '{name}' run failed: {exc}") - - return - - line = f"workflow '{name}' finished: {result.status}" - summary = (result.summary or "").strip().replace("\n", " ") - - if summary: - line += f" \u2014 {summary[:240]}" - - if result.status == "ok": - io.tool_output(line) - else: - io.tool_error(line) - - @classmethod - def _cmd_show(cls, io, coder, name) -> None: - """Dump the workflow definition as YAML.""" - import yaml - - wf = cls._require(io, coder, name, "show") - if wf is None: - return - - io.tool_output(yaml.dump(wf.to_dict(), sort_keys=False, default_flow_style=False)) - - @classmethod - async def _cmd_status(cls, io, coder, name) -> None: - """Show scheduler job rows and active leases, or fall back to the list view.""" - if name and not cls._find(coder, name): - io.tool_error(cls._unknown_message(coder, name)) - return - - jobs = await cls._job_rows(coder, name=name or None) - leases = await cls._active_leases(coder, name=name or None) - - if not jobs and not leases: - # No scheduler DB state visible — degrade to the config view. - if name: - cls._cmd_show(io, coder, name) - else: - await cls._cmd_list(io, coder) - io.tool_output("(scheduler not running — showing config view)") - return - - if jobs: - io.print("Scheduler jobs:") - for job_name, row in sorted(jobs.items()): - io.print( - f" - {job_name}: enabled={row.get('enabled')}" - f" engine={row.get('engine') or '-'}" - f" next_run={row.get('next_run') or '-'}" - f" last_run={row.get('last_run') or '-'}" - f" ({row.get('last_status') or '-'})" - ) - - if leases: - io.print("Active leases:") - for lease in leases: - io.print( - f" - {lease.get('name')}: slot={lease.get('slot')}" - f" instance={lease.get('instance_id')}" - f" expires={lease.get('lease_expires_at')}" - ) - - @classmethod - async def _cmd_last(cls, io, coder, name) -> None: - """Summarize the most recent run of a workflow (DB row, else run log).""" - from cecli.helpers.workflows import logs - - if not name: - io.tool_error("Usage: /workflows last ") - return - - wf = cls._find(coder, name) - lookup = wf.name if wf else name.removeprefix("wf:") - - row = await cls._recent_run(coder, lookup) - if row is not None: - io.print(f"Last run of '{lookup}' ({row.get('finished_at') or row.get('started_at')}):") - io.print(f" Status: {row.get('status')}") - io.print(f" Source: {row.get('source')}") - if row.get("summary"): - io.print(f" Summary: {row.get('summary')}") - return - - path = logs.latest_log_path(lookup) - if path is None: - io.tool_output(f"No recorded runs for '{lookup}'.") - return - - lines = logs.read_log_lines(path) - finish = next((ln for ln in reversed(lines) if ln.get("event") == "finish"), {}) - io.print(f"Last run of '{lookup}' (log: {path}):") - io.print(f" Status: {finish.get('status', '?')}") - io.print(f" Source: {finish.get('source', '?')}") - if finish.get("summary"): - io.print(f" Summary: {finish.get('summary')}") - for ln in lines: - if ln.get("event") == "task": - io.print(f" task[{ln.get('idx')}] {ln.get('label')}: {ln.get('status')}") - - @classmethod - async def _cmd_toggle(cls, io, coder, name, enable) -> None: - """Flip wf.enabled for the session and (un)register the wf: agent.""" - from cecli.helpers.workflows.subagents import ( - register_workflow_subagents, - unregister_workflow_subagents, - ) - - wf = cls._require(io, coder, name, "enable" if enable else "disable") - if wf is None: - return - - wf.enabled = bool(enable) - if enable: - register_workflow_subagents([wf]) - else: - unregister_workflow_subagents([wf]) - - db = cls._scheduler_db(coder) - if db is not None: - method = getattr(db, "enable" if enable else "disable", None) - if method is not None: - try: - await ( - method(wf.name) if enable else method(wf.name, "disabled via /workflows") - ) - except TypeError: - try: - await method(wf.name) - except Exception: - logger.warning("scheduler db sync failed", exc_info=True) - except Exception: - logger.warning("scheduler db sync failed", exc_info=True) - - state = "enabled" if enable else "disabled" - io.tool_output( - f"Workflow '{wf.name}' {state} for this session (files unchanged —" - " /workflows reload re-reads them)." - ) - - @classmethod - def _cmd_reload(cls, io, coder) -> None: - """Re-read workflow definitions and refresh the wf: agent registrations.""" - try: - from cecli.helpers.workflows import load_workflows - - workflows = load_workflows( - config_arg=getattr(coder, "workflows_config_arg", None), - merged_config=getattr(coder, "workflows_merged_config", None), - git_root=getattr(coder, "root", None), - ) - except Exception as exc: - io.tool_error(f"Error reloading workflows: {exc}") - return - - from cecli.helpers.workflows.subagents import ( - get_workflow_configs, - register_workflow_subagents, - unregister_workflow_subagents, - ) - - # Files re-read here may not cover sources the startup load had access - # to (inline --workflows / merged config); keep those cached configs so - # they don't silently vanish from the registry on reload. - previous = get_workflow_configs() - by_name = {wf.name: wf for wf in previous} - by_name.update({wf.name: wf for wf in workflows}) - merged = sorted(by_name.values(), key=lambda w: w.name) - - unregister_workflow_subagents(previous) - registered = register_workflow_subagents(merged) - - # A running scheduler must learn the new definitions too (hash change - # reseeds next_run; vanished names get disabled) - fire-and-forget. - scheduler = getattr(coder, "scheduler", None) - if scheduler is not None and getattr(scheduler, "db", None) is not None: - import asyncio - - try: - asyncio.get_running_loop().create_task( - scheduler.db.sync_definitions({wf.name: wf for wf in merged if wf.enabled}) - ) - except RuntimeError: - pass - - try: - coder.workflows = merged - except Exception: - pass - - io.tool_output( - f"Reloaded {len(merged)} workflow(s);" - f" {len(registered)} agent(s) registered: {', '.join(registered) or 'none'}" - ) - - # ------------------------------------------------------------------ # - # Private helpers - # ------------------------------------------------------------------ # - - @classmethod - def _workflows(cls, coder) -> list: - """The loaded WorkflowConfigs: coder-provided first, else the register cache.""" - workflows = getattr(coder, "workflows", None) - if isinstance(workflows, (list, tuple)): - return list(workflows) - - from cecli.helpers.workflows.subagents import get_workflow_configs - - return get_workflow_configs() - - @classmethod - def _find(cls, coder, name): - if not name: - return None - - bare = name[3:] if name.startswith("wf:") else name - for wf in cls._workflows(coder): - if wf.name == bare: - return wf - - return None - - @classmethod - def _require(cls, io, coder, name, sub): - """Resolve a required workflow-name argument or emit the error.""" - if not name: - io.tool_error(f"Usage: /workflows {sub} ") - return None - - wf = cls._find(coder, name) - if wf is None: - io.tool_error(cls._unknown_message(coder, name)) - - return wf - - @classmethod - def _unknown_message(cls, coder, name): - known = ", ".join(sorted(wf.name for wf in cls._workflows(coder))) or "none loaded" - return f"Error: unknown workflow '{name}'. Available: {known}" - - @staticmethod - def _scheduler_db(coder): - scheduler = getattr(coder, "scheduler", None) - return getattr(scheduler, "db", None) - - @classmethod - async def _job_rows(cls, coder, name=None): - """jobs-table rows keyed by name (empty dict when no scheduler DB).""" - db = cls._scheduler_db(coder) - if db is None: - return {} - - list_jobs = getattr(db, "list_jobs", None) - if list_jobs is None: - return {} - - try: - rows = await list_jobs() - except Exception: - logger.warning("scheduler list_jobs failed", exc_info=True) - return {} - - jobs = {} - for row in rows or []: - row = dict(row) - key = row.get("name") - if key and (name is None or key == name): - jobs[key] = row - - return jobs - - @classmethod - async def _active_leases(cls, coder, name=None): - """Currently-held leases, if the scheduler DB exposes them.""" - db = cls._scheduler_db(coder) - method = getattr(db, "active_leases", None) if db is not None else None - if method is None: - return [] - - try: - rows = await (method(name) if name else method()) - except TypeError: - try: - rows = await method() - except Exception: - logger.warning("scheduler active_leases failed", exc_info=True) - return [] - except Exception: - logger.warning("scheduler active_leases failed", exc_info=True) - return [] - - rows = [dict(r) for r in rows or []] - return [r for r in rows if name is None or r.get("name") == name] - - @classmethod - async def _recent_run(cls, coder, name): - """The newest runs-table row for *name*, if a scheduler DB is present.""" - db = cls._scheduler_db(coder) - recent_runs = getattr(db, "recent_runs", None) if db is not None else None - if recent_runs is None: - return None - - try: - rows = await recent_runs(name, limit=1) - except TypeError: - try: - rows = await recent_runs(name) - except Exception: - logger.warning("scheduler recent_runs failed", exc_info=True) - return None - except Exception: - logger.warning("scheduler recent_runs failed", exc_info=True) - return None - - return dict(rows[0]) if rows else None diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index a920690b189..a256f28c832 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -979,7 +979,7 @@ async def _cleanup(): logger.debug("Sub-agent %s generate cancelled (interrupted)", info.name) raise except SwitchCoderSignal as switch: - # A command run mid-turn (e.g. /load-mcp) asks for this + # A command run mid-turn (e.g. /mcp load) asks for this # sub-agent's coder to be rebuilt. Absorb the signal here: # letting it escape would recreate the *primary* coder from a # sub-agent. The switch also drains any prompts staged behind diff --git a/cecli/io.py b/cecli/io.py index e4d55eb45da..7e08fddc35c 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -224,8 +224,10 @@ def get_command_completions(self, document, complete_event, text, words): # Compare with command names without leading '/' candidates = [cmd for cmd in self.command_names if cmd[1:].startswith(partial)] for candidate in sorted(candidates): - # Add back the leading '/' for the completion - yield Completion(candidate, start_position=-len(words[-1])) + # Advance to the subcommand section, but never emit two + # spaces in a row when the user already typed one. + suffix = " " if self.commands.has_subcommands(candidate) else "" + yield Completion(candidate.rstrip() + suffix, start_position=-len(words[-1])) return # Handle command followed by space: trigger auto-completion with empty partial @@ -915,7 +917,18 @@ def _(event): @kb.add("c-space") def _(event): - "Ignore Ctrl when pressing space bar" + """Ignore Ctrl when pressing space bar""" + event.current_buffer.insert_text(" ") + + @kb.add(" ") + def _(event): + """Insert one space, but not a second at a subcommand boundary.""" + text = event.current_buffer.text + check_boundary = getattr(commands, "at_subcommand_boundary", None) + if text.endswith(" ") and check_boundary and check_boundary(text): + event.current_buffer.start_completion(select_first=False) + return + event.current_buffer.insert_text(" ") @kb.add("c-up") diff --git a/cecli/tui/app.py b/cecli/tui/app.py index d51505184cd..0d29c3cf6b5 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -2088,7 +2088,7 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: has_completions = False if has_completions: - return completion + " " + return completion.rstrip() + " " else: return completion else: @@ -2111,7 +2111,8 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: def on_input_area_completion_requested(self, message: InputArea.CompletionRequested): """Handle completion request - show or update completion bar.""" input_area = self.query_one("#input", InputArea) - text = message.text + text = input_area.text + input_area._completion_prefix = text suggestions = self._get_suggestions(text) # Check if completion bar already exists diff --git a/cecli/tui/widgets/floating_panels.py b/cecli/tui/widgets/floating_panels.py index 0d11c37b556..abce0b52810 100644 --- a/cecli/tui/widgets/floating_panels.py +++ b/cecli/tui/widgets/floating_panels.py @@ -250,15 +250,17 @@ def _run_coder_command(self, category, name, active): if coder is None: return - command_cls = self._command_class(category, active) - if command_cls is None: + command = self._command_for(category, name, active) + if command is None: return + command_cls, command_args = command + self._command_running = True async def runner(): try: - await command_cls.execute(coder.io, coder, name) + await command_cls.execute(coder.io, coder, command_args) finally: self._command_running = False try: @@ -276,32 +278,24 @@ async def runner(): except Exception: self._command_running = False - def _command_class(self, category, active): - """Return the command class for an item click.""" + def _command_for(self, category, name, active): + """Return (command class, argument string) for an item click.""" if category == "subagents": from cecli.commands.spawn_agent import SpawnAgentCommand - return SpawnAgentCommand + return SpawnAgentCommand, name if category == "skills": - if active: - from cecli.commands.remove_skill import RemoveSkillCommand - - return RemoveSkillCommand - - from cecli.commands.load_skill import LoadSkillCommand + from cecli.commands.skills import SkillsCommand - return LoadSkillCommand + sub = "remove" if active else "load" + return SkillsCommand, f"{sub} {name}" if category == "servers": - if active: - from cecli.commands.remove_mcp import RemoveMcpCommand - - return RemoveMcpCommand - - from cecli.commands.load_mcp import LoadMcpCommand + from cecli.commands.mcp import McpCommand - return LoadMcpCommand + sub = "remove" if active else "load" + return McpCommand, f"{sub} {name}" return None diff --git a/cecli/tui/widgets/input_area.py b/cecli/tui/widgets/input_area.py index 198dd70dd84..f5608045d6c 100644 --- a/cecli/tui/widgets/input_area.py +++ b/cecli/tui/widgets/input_area.py @@ -246,6 +246,14 @@ def on_key(self, event) -> None: if not is_cycle: self._cycling = False + if event.key == "space" and self._blocks_extra_space(): + event.stop() + event.prevent_default() + self._cycling = False + self._completion_prefix = self.text + self.post_message(self.CompletionRequested(self.text)) + return + if event.key == "space" and self.completion_active: self.completion_active = False self.post_message(self.CompletionDismiss()) @@ -396,6 +404,20 @@ def on_text_area_changed(self, event) -> None: if val.startswith("/") or "@" in val or possible_path or self.completion_active: self.post_message(self.CompletionRequested(val)) + def _blocks_extra_space(self) -> bool: + """Return True when another space would duplicate a command separator.""" + text = self.text + if not text.endswith(" "): + return False + + try: + commands = self.app.worker.coder.commands + except Exception: + return False + + check_boundary = getattr(commands, "at_subcommand_boundary", None) + return bool(check_boundary and check_boundary(text)) + def _refresh_size(self) -> None: """Refresh size, clamping cursor to document bounds first. diff --git a/cecli/website/docs/config/skills.md b/cecli/website/docs/config/skills.md index c9ceff775fb..09e15a00da8 100644 --- a/cecli/website/docs/config/skills.md +++ b/cecli/website/docs/config/skills.md @@ -106,26 +106,26 @@ agent-config: | You can import a skills from the cecli community registry or from [skills.sh](https://www.skills.sh). Importing is only available in Agent Mode. -In the chat, use the `/import-skill` command: +In the chat, use the `/skills import` command: ``` -/import-skill # Import into the project's .cecli/skills -/import-skill --global # Import into ~/.cecli/skills +/skills import # Import into the project's .cecli/skills +/skills import --global # Import into ~/.cecli/skills ``` The skill name may be a path within the registry, for example `web/browser-harness`, `files/docx`, or `pdf`. cecli looks the skill up in the community registry (`cecli-dev/community-resources`) first and falls back to skills.sh if it is not found there. -- `/import-skill ` downloads the skill into the project's `.cecli/skills` directory. -- `/import-skill --global ` (or `-g`) downloads the skill into `~/.cecli/skills`, making it available across all projects. +- `/skills import ` downloads the skill into the project's `.cecli/skills` directory. +- `/skills import --global ` (or `-g`) downloads the skill into `~/.cecli/skills`, making it available across all projects. For example: ``` -/import-skill files/docx # Import the docx skill from the community registry -/import-skill --global pdf # Install the PDF skill globally +/skills import files/docx # Import the docx skill from the community registry +/skills import --global pdf # Install the PDF skill globally ``` -After importing, the skill is added to the current session just like `/include-skill`. If your configuration has a `skills_includelist`, the skill is also added to it so it survives restarts; otherwise the skill is auto-discovered in future sessions. +After importing, the skill is added to the current session just like `/skills include`. If your configuration has a `skills_includelist`, the skill is also added to it so it survives restarts; otherwise the skill is auto-discovered in future sessions. See the [community-resources repository](https://github.com/cecli-dev/community-resources) for the list of available skills. diff --git a/cecli/website/docs/config/workflows.md b/cecli/website/docs/config/workflows.md index b516c4c3f87..6da9a679a04 100644 --- a/cecli/website/docs/config/workflows.md +++ b/cecli/website/docs/config/workflows.md @@ -47,7 +47,7 @@ servers: [gitlab] # optional -> active MCP servers (lowercased) on_failure: stop # stop | continue tasks: - name: attach-gitlab - prompt: /load-mcp gitlab + prompt: /mcp load gitlab - name: triage prompt: > Monitor the gitlab test pipeline and recent builds; summarize any errors @@ -80,7 +80,7 @@ Task fields: | field | default | meaning | |-------|---------|---------| -| `prompt` | — | Required. A list entry may also be a bare string, which is taken as the prompt. Slash commands (`/load-mcp`, `/model`, …) work exactly as if typed | +| `prompt` | — | Required. A list entry may also be a bare string, which is taken as the prompt. Slash commands (`/mcp load`, `/model`, …) work exactly as if typed | | `name` | `task-N` | Label used in run logs and status output | | `save_to` | none | On success, writes the run's final summary to a persistent per-run file under `~/.cecli/workflows/outputs/{workflow}/{run}/` (named after the key; the path is recorded in the run log) | @@ -131,7 +131,7 @@ schedule: "@every 20m" servers: [github] on_failure: continue tasks: - - prompt: /load-mcp github + - prompt: /mcp load github continue_on_error: true - prompt: "List open PRs I haven't reviewed; for each, summarize the diff and flag risky changes." save_to: pr_review_notes diff --git a/cecli/website/docs/usage/commands.md b/cecli/website/docs/usage/commands.md index 2560b0f8555..a290080b8c2 100644 --- a/cecli/website/docs/usage/commands.md +++ b/cecli/website/docs/usage/commands.md @@ -32,10 +32,10 @@ Cecli supports commands from within the chat, which all start with `/`. | **/history-search** | Fuzzy search your command history and paste the selected command into the chat. | | **/lint** | Lint and fix in-chat files or all dirty files if none in chat | | **/load** | Load and execute commands from a file | -| **/load-mcp** | Load a MCP server by name | | **/ls** | List all known files and indicate which are included in the chat session | | **/map** | Print out the current repository map | | **/map-refresh** | Force a refresh of the repository map | +| **/mcp** | Manage MCP servers: `/mcp list`, `/mcp load `, `/mcp remove ` | | **/model** | Switch the Main Model to a new LLM | | **/models** | Search the list of available models | | **/multiline-mode** | Toggle multiline mode (swaps behavior of Enter and Meta+Enter) | @@ -44,11 +44,11 @@ Cecli supports commands from within the chat, which all start with `/`. | **/read-only** | Add files to the chat that are for reference only, or turn added files to read-only | | **/reasoning-effort** | Set the reasoning effort level (values: number or low/medium/high depending on model) | | **/report** | Report a problem by opening a GitHub Issue | -| **/remove-mcp** | Remove a MCP server by name | | **/reset** | Drop all files and clear the chat history | | **/run** | Run a shell command and optionally add the output to the chat (alias: !) | | **/save** | Save commands to a file that can reconstruct the current chat session's files | | **/settings** | Print out the current settings | +| **/skills** | Manage skills: `/skills list`, `load`, `include`, `exclude`, `remove`, `import` | | **/switch-agent** | Switch to a specific agent by name | | **/test** | Run a shell command and add the output to the chat on non-zero exit code | | **/think-tokens** | Set the thinking token budget, eg: 8096, 8k, 10.5k, 0.5M, or 0 to disable. | diff --git a/cecli/website/docs/usage/modes.md b/cecli/website/docs/usage/modes.md index 960421375f2..51ca0b3fa60 100644 --- a/cecli/website/docs/usage/modes.md +++ b/cecli/website/docs/usage/modes.md @@ -61,7 +61,7 @@ You can activate agent mode in several ways: - **Autonomous file management**: cecli discovers and manages relevant files itself, rather than relying only on the files you explicitly add. - **Enhanced context management**: entering agent mode enables context management for large files, and you can tune it with `/context-management` and `/context-blocks`. -- **Skills**: loading, including, excluding, and removing skills is only available in agent mode (`/load-skill`, `/include-skill`, `/exclude-skill`, `/remove-skill`, `/import-skill`). +- **Skills**: loading, including, excluding, removing, and importing skills is only available in agent mode (`/skills load`, `/skills include`, `/skills exclude`, `/skills remove`, `/skills import`). - **Sub-agents**: delegate sub-tasks to specialized sub-agents for parallel or focused work. - **Dedicated agent model**: choose a separate model for agent mode with `--agent-model` or `/agent-model`. diff --git a/tests/basic/test_io.py b/tests/basic/test_io.py index cd838cbfbb7..7e9b51099b1 100644 --- a/tests/basic/test_io.py +++ b/tests/basic/test_io.py @@ -83,6 +83,7 @@ def test_dumb_terminal(self): def test_autocompleter_get_command_completions(self): # Step 3: Mock the commands object commands = MagicMock() + commands.has_subcommands.return_value = False commands.get_commands.return_value = ["/help", "/add", "/drop"] commands.matching_commands.side_effect = lambda inp: ( [cmd for cmd in commands.get_commands() if cmd.startswith(inp.strip().split()[0])], diff --git a/tests/commands/test_mcp_skills_completions.py b/tests/commands/test_mcp_skills_completions.py new file mode 100644 index 00000000000..4791d2d8591 --- /dev/null +++ b/tests/commands/test_mcp_skills_completions.py @@ -0,0 +1,209 @@ +"""Completion behavior for the consolidated /mcp and /skills commands.""" + +import pytest + +import cecli.helpers.extensions.skills_importer as skills_importer +from cecli.commands.mcp import McpCommand +from cecli.commands.skills import SkillsCommand + + +class _Server: + def __init__(self, name, connected): + self.name = name + self.is_connected = connected + + +class _McpManager: + def __init__(self): + self.servers = [ + _Server("github", True), + _Server("context7", False), + _Server("unnamed-server", False), + ] + self.connected_servers = [server for server in self.servers if server.is_connected] + + def __iter__(self): + return iter(self.servers) + + +class _Skill: + def __init__(self, name): + self.name = name + + +class _SkillsManager: + def find_skills(self): + return [_Skill("pdf"), _Skill("docx")] + + +class _Coder: + pass + + +@pytest.fixture +def mcp_coder(): + coder = _Coder() + coder.mcp_manager = _McpManager() + return coder + + +@pytest.fixture +def skills_coder(monkeypatch): + coder = _Coder() + coder.skills_manager = _SkillsManager() + monkeypatch.setattr( + skills_importer, "get_registry_skills", lambda: ["pdf", "docx", "files/slides"] + ) + return coder + + +@pytest.mark.parametrize( + "args, expected", + [ + ("", ["list", "load", "remove"]), + ("l", ["list", "load"]), + ("load ", ["context7"]), + ("load c", ["context7"]), + ("load context7 ", ["context7"]), + ("remove ", ["github"]), + ("remove github ", ["github"]), + ("list ", []), + ], +) +def test_mcp_completions(mcp_coder, args, expected): + assert McpCommand.get_completions(None, mcp_coder, args) == expected + + +@pytest.mark.parametrize( + "args, expected", + [ + ("", ["list", "load", "include", "exclude", "remove", "import"]), + ("l", ["list", "load"]), + ("load ", ["pdf", "docx"]), + ("include ", ["pdf", "docx"]), + ("include pdf ", ["pdf", "docx"]), + ("include d", ["docx"]), + ("import ", ["--global", "pdf", "docx", "files/slides"]), + ("import --", ["--global"]), + ("import --global ", ["pdf", "docx", "files/slides"]), + ("import --global d", ["docx"]), + ("import -g ", ["pdf", "docx", "files/slides"]), + ("list ", []), + ], +) +def test_skills_completions(skills_coder, args, expected): + assert SkillsCommand.get_completions(None, skills_coder, args) == expected + + +def test_mcp_and_skills_receive_full_args_for_completion(): + from cecli.commands.core import Commands + + assert "/mcp" in Commands.FULL_ARGS_COMPLETION_COMMANDS + assert "/skills" in Commands.FULL_ARGS_COMPLETION_COMMANDS + + +def _io_completer(coder): + from cecli.commands.core import Commands + from cecli.io import AutoCompleter + + completer = AutoCompleter.__new__(AutoCompleter) + completer.commands = Commands(None, coder) + completer.command_names = completer.commands.get_commands() + return completer + + +def test_io_completes_skills_subcommands_and_names(skills_coder): + completer = _io_completer(skills_coder) + + command = list(completer.get_command_completions(None, None, "/skill", ["/skill"])) + assert any(c.text == "/skills " for c in command) + + subcommands = list(completer.get_command_completions(None, None, "/skills ", ["/skills"])) + assert {c.text for c in subcommands} == { + "list", + "load", + "include", + "exclude", + "remove", + "import", + } + assert not any(c.text.endswith(" ") for c in subcommands) + + load = list(completer.get_command_completions(None, None, "/skills l", ["/skills", "l"])) + assert {c.text for c in load} == {"list", "load"} + + names = list( + completer.get_command_completions(None, None, "/skills load ", ["/skills", "load"]) + ) + assert {c.text for c in names} == {"pdf", "docx"} + + +def test_io_completes_mcp_names(mcp_coder): + completer = _io_completer(mcp_coder) + + command = list(completer.get_command_completions(None, None, "/mc", ["/mc"])) + assert any(c.text == "/mcp " for c in command) + + no_subcommands = list(completer.get_command_completions(None, None, "/ad", ["/ad"])) + assert no_subcommands + assert not any(c.text.endswith(" ") for c in no_subcommands) + + subcommands = list(completer.get_command_completions(None, None, "/mcp ", ["/mcp"])) + assert {c.text for c in subcommands} == {"list", "load", "remove"} + assert not any(c.text.endswith(" ") for c in subcommands) + + names = list(completer.get_command_completions(None, None, "/mcp load ", ["/mcp", "load"])) + assert {c.text for c in names} == {"context7"} + + +def test_at_subcommand_boundary_blocks_duplicate_spaces(): + from cecli.commands.core import Commands + + commands = Commands(None, None) + + assert commands.at_subcommand_boundary("/skills ") + assert commands.at_subcommand_boundary("/skills l ") + assert commands.at_subcommand_boundary("/skills load ") + assert commands.at_subcommand_boundary("/mcp load ") + assert commands.at_subcommand_boundary("/workflows run ") + + assert not commands.at_subcommand_boundary("/skills") + assert not commands.at_subcommand_boundary("/skills load") + assert not commands.at_subcommand_boundary("/skills load pdf ") + assert not commands.at_subcommand_boundary("/add ") + assert not commands.at_subcommand_boundary("/skillsx ") + + +def _tui_app(coder): + from unittest.mock import MagicMock + + from cecli.commands.core import Commands + from cecli.tui.app import TUI + + app = TUI.__new__(TUI) + app.worker = MagicMock() + app.worker.coder.commands = Commands(None, coder) + return app + + +def test_tui_subcommand_completion_preserves_host(skills_coder): + app = _tui_app(skills_coder) + + assert app._get_completed_text("/skills ", "load") == "/skills load" + assert app._get_completed_text("/skills load ", "pdf") == "/skills load pdf" + + +def test_tui_completion_request_syncs_prefix(): + from unittest.mock import MagicMock + + from cecli.tui.app import TUI + + app = TUI.__new__(TUI) + input_area = MagicMock() + input_area.text = "/skills " + app.query_one = lambda *args, **kwargs: input_area + app._get_suggestions = lambda text: [] + + app.on_input_area_completion_requested(MagicMock(text="/skil")) + + assert input_area._completion_prefix == "/skills " diff --git a/tests/commands/test_queue_commands.py b/tests/commands/test_queue_commands.py index edb2dd70dc9..c68523b5407 100644 --- a/tests/commands/test_queue_commands.py +++ b/tests/commands/test_queue_commands.py @@ -735,7 +735,7 @@ async def fake_drain(preproc=True): coder.preproc_user_input = fake_preproc coder.process_prompt_queue = fake_drain - await Coder.run_one(coder, "/load-mcp github", preproc=True) + await Coder.run_one(coder, "/mcp load github", preproc=True) assert calls == ["preproc", "drain"] assert processed == ["queued prompt"] diff --git a/tests/commands/test_workflows.py b/tests/commands/test_workflows.py index 416ba065af5..43b62c2a07f 100644 --- a/tests/commands/test_workflows.py +++ b/tests/commands/test_workflows.py @@ -79,7 +79,7 @@ async def test_list_without_workflows(mock_coder, mock_io): async def test_run_starts_background_task(mock_io): coder = _coder_with(_wf("demo")) with ( - patch("cecli.commands.workflows.asyncio.create_task") as mock_create, + patch("cecli.commands.workflow_helpers.run.asyncio.create_task") as mock_create, patch("cecli.helpers.workflows.runner.execute_workflow") as mock_exec, ): # the create_task stub closes the (already-recorded) coroutine so the @@ -87,7 +87,7 @@ async def test_run_starts_background_task(mock_io): mock_create.side_effect = lambda coro: (coro.close(), MagicMock())[1] await WorkflowCommand.execute(mock_io, coder, "run demo") with ( - patch("cecli.commands.workflows.asyncio.create_task") as mock_create, + patch("cecli.commands.workflow_helpers.run.asyncio.create_task") as mock_create, patch("cecli.helpers.workflows.runner.execute_workflow", new=MagicMock()) as mock_exec, ): await WorkflowCommand.execute(mock_io, coder, "run demo") @@ -101,10 +101,10 @@ async def test_run_starts_background_task(mock_io): async def test_run_reports_completion_and_releases_task_ref(mock_io): """Reviewer MUST-FIX 2: the background run must survive (strong ref) and announce its summary in the conversation when done.""" - from cecli.commands import workflows as wf_module + from cecli.commands.workflow_helpers import run as run_module from cecli.helpers.workflows.models import RunResult - wf_module._bg_runs.clear() + run_module._bg_runs.clear() coder = _coder_with(_wf("demo")) finished = RunResult(name="demo", status="ok", summary="all good") @@ -121,15 +121,15 @@ async def fake_exec(coder_, wf_, *, source="manual", run_id=None): assert "started in the background" in out assert "finished: ok" in out assert "all good" in out - assert not wf_module._bg_runs # reference released by the done-callback + assert not run_module._bg_runs # reference released by the done-callback @pytest.mark.asyncio async def test_run_reports_error_status_via_tool_error(mock_io): - from cecli.commands import workflows as wf_module + from cecli.commands.workflow_helpers import run as run_module from cecli.helpers.workflows.models import RunResult - wf_module._bg_runs.clear() + run_module._bg_runs.clear() coder = _coder_with(_wf("demo")) bad = RunResult(name="demo", status="error", error_message="boom", summary="boom") @@ -143,7 +143,7 @@ async def fake_exec(coder_, wf_, *, source="manual", run_id=None): err = "\n".join(str(c.args[0]) for c in mock_io.tool_error.call_args_list) assert "finished: error" in err - assert not wf_module._bg_runs + assert not run_module._bg_runs @pytest.mark.asyncio diff --git a/tests/helpers/workflows/test_runner.py b/tests/helpers/workflows/test_runner.py index 1d51857109f..d14748198b7 100644 --- a/tests/helpers/workflows/test_runner.py +++ b/tests/helpers/workflows/test_runner.py @@ -146,14 +146,14 @@ async def test_command_first_task_drains_staged_queue(coder): worker.received = [] service = FakeService(worker, _drain) wf = _wf( - TaskConfig(prompt="/load-mcp github"), + TaskConfig(prompt="/mcp load github"), TaskConfig(prompt="triage issues"), ) result = await _execute(service, wf, coder) _, first_text, queue_at_start, drain_after_command = service.calls[1] - assert first_text == "/load-mcp github" + assert first_text == "/mcp load github" assert [item["text"] for item in queue_at_start] == ["triage issues"] assert drain_after_command is True assert result.status == "ok" diff --git a/tests/integration/test_workflows_e2e.py b/tests/integration/test_workflows_e2e.py index b47214697dc..23d1f58a022 100644 --- a/tests/integration/test_workflows_e2e.py +++ b/tests/integration/test_workflows_e2e.py @@ -52,7 +52,7 @@ - Demo-MCP tasks: - name: attach-demo - prompt: /load-mcp demo + prompt: /mcp load demo - name: write-report prompt: summarize the demo run into .cecli/reports/demo.md """ @@ -83,7 +83,7 @@ async def test_yaml_config_drives_two_runs_through_real_db(tmp_path, monkeypatch configs = load_workflows() wf = _only_demo(configs) assert [t.prompt for t in wf.tasks] == [ - "/load-mcp demo", + "/mcp load demo", "summarize the demo run into .cecli/reports/demo.md", ], "task order must survive YAML loading" diff --git a/tests/mcp/test_manager_retry.py b/tests/mcp/test_manager_retry.py index 1e5bce8a2e1..e9e2ff4262c 100644 --- a/tests/mcp/test_manager_retry.py +++ b/tests/mcp/test_manager_retry.py @@ -570,14 +570,14 @@ async def test_from_servers_warning_after_retries(mock_server, mock_io): # --------------------------------------------------------------------------- -# TC-017: /load-mcp command benefits from retry logic +# TC-017: /mcp load command benefits from retry logic # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_load_mcp_command_benefits_from_retry(mock_server, mock_io, mock_tools, mock_session): - """TC-017: LoadMcpCommand.execute uses connect_server which now has built-in retry.""" - from cecli.commands.load_mcp import LoadMcpCommand + """TC-017: McpCommand.execute uses connect_server which now has built-in retry.""" + from cecli.commands.mcp import McpCommand coder = _make_mock_coder(mock_server, mock_io) @@ -593,31 +593,31 @@ async def _connect_with_retry(name): coder.mcp_manager.get_server = MagicMock(return_value=mock_server) coder.mcp_manager.connected_servers = [] - with patch("cecli.commands.load_mcp.iter_all_coders", return_value=[coder]): - with patch("cecli.commands.load_mcp.update_server_registration"): + with patch("cecli.commands.mcp_helpers.load.iter_all_coders", return_value=[coder]): + with patch("cecli.commands.mcp_helpers.load.update_server_registration"): with pytest.raises(SwitchCoderSignal): - await LoadMcpCommand.execute(mock_io, coder, "test-server") + await McpCommand.execute(mock_io, coder, "load test-server") # --------------------------------------------------------------------------- -# TC-018: /load-mcp command reports failure after retries exhausted +# TC-018: /mcp load command reports failure after retries exhausted # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_load_mcp_command_reports_failure_after_retries(mock_server, mock_io): - """TC-018: LoadMcpCommand.execute reports 'Unable to load server' after retries exhausted.""" - from cecli.commands.load_mcp import LoadMcpCommand + """TC-018: McpCommand.execute reports 'Unable to load server' after retries exhausted.""" + from cecli.commands.mcp import McpCommand coder = _make_mock_coder(mock_server, mock_io) coder.mcp_manager.connect_server = AsyncMock(return_value=False) coder.mcp_manager.get_server = MagicMock(return_value=mock_server) coder.mcp_manager.connected_servers = [] - with patch("cecli.commands.load_mcp.iter_all_coders", return_value=[coder]): - with patch("cecli.commands.load_mcp.update_server_registration"): + with patch("cecli.commands.mcp_helpers.load.iter_all_coders", return_value=[coder]): + with patch("cecli.commands.mcp_helpers.load.update_server_registration"): with pytest.raises(SwitchCoderSignal): - await LoadMcpCommand.execute(mock_io, coder, "test-server") + await McpCommand.execute(mock_io, coder, "load test-server") # Check that the results were output - "Unable to load server" should be in output output_calls = [str(call) for call in mock_io.tool_output.call_args_list] @@ -626,14 +626,14 @@ async def test_load_mcp_command_reports_failure_after_retries(mock_server, mock_ # --------------------------------------------------------------------------- -# TC-019: /load-mcp interruptible wrapper propagates cancellation during retry +# TC-019: /mcp load interruptible wrapper propagates cancellation during retry # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_load_mcp_propagates_cancellation_during_retry(mock_server, mock_io): - """TC-019: LoadMcpCommand interruptible wrapper correctly propagates CancelledError.""" - from cecli.commands.load_mcp import LoadMcpCommand + """TC-019: McpCommand interruptible wrapper correctly propagates CancelledError.""" + from cecli.commands.mcp import McpCommand coder = _make_mock_coder(mock_server, mock_io) @@ -650,10 +650,10 @@ async def _propagate_interruptible(coro, event): coder.coroutines.interruptible = _propagate_interruptible - with patch("cecli.commands.load_mcp.iter_all_coders", return_value=[coder]): - with patch("cecli.commands.load_mcp.update_server_registration"): + with patch("cecli.commands.mcp_helpers.load.iter_all_coders", return_value=[coder]): + with patch("cecli.commands.mcp_helpers.load.update_server_registration"): with pytest.raises(asyncio.CancelledError): - await LoadMcpCommand.execute(mock_io, coder, "test-server") + await McpCommand.execute(mock_io, coder, "load test-server") # --------------------------------------------------------------------------- diff --git a/tests/mcp/test_server_name_normalization.py b/tests/mcp/test_server_name_normalization.py index ad16ba13cc7..075851b9217 100644 --- a/tests/mcp/test_server_name_normalization.py +++ b/tests/mcp/test_server_name_normalization.py @@ -14,7 +14,7 @@ from cecli.coders import Coder from cecli.coders.agent_coder import AgentCoder from cecli.commands import SwitchCoderSignal -from cecli.commands.load_mcp import LoadMcpCommand +from cecli.commands.mcp import McpCommand from cecli.commands.utils.helpers import ( is_server_globally_excluded, update_server_registration, @@ -218,9 +218,9 @@ async def _passthrough(coro, event): coder.coroutines = MagicMock() coder.coroutines.interruptible = _passthrough - with patch("cecli.commands.load_mcp.iter_all_coders", return_value=[coder]): + with patch("cecli.commands.mcp_helpers.load.iter_all_coders", return_value=[coder]): with pytest.raises(SwitchCoderSignal): - await LoadMcpCommand.execute(coder.io, coder, "GitHub") + await McpCommand.execute(coder.io, coder, "load GitHub") assert coder.registered_servers["included"] == {"github", "local"} diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py index 3eede1493fc..a02902d39fc 100644 --- a/tests/subagents/test_service.py +++ b/tests/subagents/test_service.py @@ -1213,7 +1213,7 @@ async def raise_switch(**kwargs): info.detached = True service.switch_sub_agent_coder = AsyncMock() - task = service.start_generate_task(info, "/load-mcp github") + task = service.start_generate_task(info, "/mcp load github") await task service.switch_sub_agent_coder.assert_awaited_once() From 5972845375b56ac370959de2f1b83720be0b1f42 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Fri, 30 Oct 2026 23:39:58 -0400 Subject: [PATCH 08/13] Command consolidation part 2: Hooks, skills, queues --- cecli/coders/base_coder.py | 2 +- cecli/commands/__init__.py | 30 +-- cecli/commands/compact.py | 2 +- cecli/commands/core.py | 11 +- cecli/commands/hooks.py | 168 ++++++++--------- cecli/commands/hooks_helpers/__init__.py | 1 + cecli/commands/hooks_helpers/common.py | 16 ++ cecli/commands/hooks_helpers/list.py | 78 ++++++++ cecli/commands/hooks_helpers/load.py | 29 +++ cecli/commands/hooks_helpers/remove.py | 29 +++ cecli/commands/insert_queue.py | 100 ---------- cecli/commands/list_queue.py | 70 ------- cecli/commands/list_sessions.py | 56 ------ cecli/commands/load_hook.py | 71 ------- cecli/commands/load_session.py | 51 ------ cecli/commands/queue.py | 173 +++++++++++------- cecli/commands/queue_helpers/__init__.py | 1 + cecli/commands/queue_helpers/add.py | 54 ++++++ cecli/commands/queue_helpers/insert.py | 52 ++++++ cecli/commands/queue_helpers/list.py | 33 ++++ cecli/commands/queue_helpers/remove.py | 63 +++++++ cecli/commands/queue_helpers/run.py | 32 ++++ cecli/commands/remove_hook.py | 72 -------- cecli/commands/remove_queue.py | 119 ------------ cecli/commands/run_queue.py | 79 -------- cecli/commands/save_session.py | 54 ------ cecli/commands/sessions.py | 85 +++++++++ cecli/commands/sessions_helpers/__init__.py | 1 + cecli/commands/sessions_helpers/common.py | 9 + cecli/commands/sessions_helpers/list.py | 25 +++ cecli/commands/sessions_helpers/load.py | 17 ++ cecli/commands/sessions_helpers/save.py | 17 ++ cecli/helpers/command_queue.py | 2 +- cecli/helpers/sessions/manager.py | 2 +- cecli/tui/app.py | 50 ++--- cecli/website/docs/config/hooks.md | 4 +- cecli/website/docs/usage/commands.md | 44 ++--- cecli/website/docs/usage/sessions.md | 60 +++--- .../commands/test_hooks_sessions_commands.py | 165 +++++++++++++++++ tests/commands/test_queue_commands.py | 159 +++++++--------- 40 files changed, 1056 insertions(+), 1030 deletions(-) create mode 100644 cecli/commands/hooks_helpers/__init__.py create mode 100644 cecli/commands/hooks_helpers/common.py create mode 100644 cecli/commands/hooks_helpers/list.py create mode 100644 cecli/commands/hooks_helpers/load.py create mode 100644 cecli/commands/hooks_helpers/remove.py delete mode 100644 cecli/commands/insert_queue.py delete mode 100644 cecli/commands/list_queue.py delete mode 100644 cecli/commands/list_sessions.py delete mode 100644 cecli/commands/load_hook.py delete mode 100644 cecli/commands/load_session.py create mode 100644 cecli/commands/queue_helpers/__init__.py create mode 100644 cecli/commands/queue_helpers/add.py create mode 100644 cecli/commands/queue_helpers/insert.py create mode 100644 cecli/commands/queue_helpers/list.py create mode 100644 cecli/commands/queue_helpers/remove.py create mode 100644 cecli/commands/queue_helpers/run.py delete mode 100644 cecli/commands/remove_hook.py delete mode 100644 cecli/commands/remove_queue.py delete mode 100644 cecli/commands/run_queue.py delete mode 100644 cecli/commands/save_session.py create mode 100644 cecli/commands/sessions.py create mode 100644 cecli/commands/sessions_helpers/__init__.py create mode 100644 cecli/commands/sessions_helpers/common.py create mode 100644 cecli/commands/sessions_helpers/list.py create mode 100644 cecli/commands/sessions_helpers/load.py create mode 100644 cecli/commands/sessions_helpers/save.py create mode 100644 tests/commands/test_hooks_sessions_commands.py diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index ed1cc94fd88..a8d752c4a19 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2051,7 +2051,7 @@ def warn_paused(): remaining = command_queue.get_queue_length(self) self.io.tool_warning( f"Queue processing interrupted. {remaining} prompt(s) remain" - " queued; use /run-queue to resume." + " queued; use /queue run to resume." ) from cecli.helpers.agents.service import AgentService diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index ac10ac759bb..191f6f8da49 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -37,13 +37,8 @@ from .history_search import HistorySearchCommand from .hooks import HooksCommand from .hot_reload import HotReloadCommand -from .insert_queue import InsertQueueCommand from .lint import LintCommand -from .list_queue import ListQueueCommand -from .list_sessions import ListSessionsCommand from .load import LoadCommand -from .load_hook import LoadHookCommand -from .load_session import LoadSessionCommand from .ls import LsCommand from .map import MapCommand from .map_refresh import MapRefreshCommand @@ -59,17 +54,14 @@ from .read_only_stub import ReadOnlyStubCommand from .reap_agent import ReapAgentCommand from .reasoning_effort import ReasoningEffortCommand -from .remove_hook import RemoveHookCommand from .remove_memory import RemoveMemoryCommand -from .remove_queue import RemoveQueueCommand from .report import ReportCommand from .reset import ResetCommand from .rules import RulesCommand from .run import RunCommand -from .run_queue import RunQueueCommand from .save import SaveCommand -from .save_session import SaveSessionCommand from .search_memory import SearchMemoryCommand +from .sessions import SessionsCommand from .settings import SettingsCommand from .skills import SkillsCommand from .spawn_agent import SpawnAgentCommand @@ -128,13 +120,8 @@ CommandRegistry.register(HistorySearchCommand) CommandRegistry.register(HooksCommand) CommandRegistry.register(HotReloadCommand) -CommandRegistry.register(InsertQueueCommand) CommandRegistry.register(LintCommand) -CommandRegistry.register(ListQueueCommand) -CommandRegistry.register(ListSessionsCommand) CommandRegistry.register(LoadCommand) -CommandRegistry.register(LoadHookCommand) -CommandRegistry.register(LoadSessionCommand) CommandRegistry.register(LsCommand) CommandRegistry.register(MapCommand) CommandRegistry.register(MapRefreshCommand) @@ -150,17 +137,14 @@ CommandRegistry.register(ReadOnlyStubCommand) CommandRegistry.register(ReapAgentCommand) CommandRegistry.register(ReasoningEffortCommand) -CommandRegistry.register(RemoveHookCommand) CommandRegistry.register(RemoveMemoryCommand) -CommandRegistry.register(RemoveQueueCommand) CommandRegistry.register(ReportCommand) CommandRegistry.register(ResetCommand) CommandRegistry.register(RulesCommand) CommandRegistry.register(RunCommand) -CommandRegistry.register(RunQueueCommand) CommandRegistry.register(SaveCommand) -CommandRegistry.register(SaveSessionCommand) CommandRegistry.register(SearchMemoryCommand) +CommandRegistry.register(SessionsCommand) CommandRegistry.register(SettingsCommand) CommandRegistry.register(SkillsCommand) CommandRegistry.register(SpawnAgentCommand) @@ -216,13 +200,8 @@ "HistorySearchCommand", "HooksCommand", "HotReloadCommand", - "InsertQueueCommand", "LintCommand", - "ListQueueCommand", - "ListSessionsCommand", "LoadCommand", - "LoadHookCommand", - "LoadSessionCommand", "LsCommand", "MapCommand", "MapRefreshCommand", @@ -241,17 +220,14 @@ "ReapAgentCommand", "ReasoningEffortCommand", "ReloadProgramSignal", - "RemoveHookCommand", "RemoveMemoryCommand", - "RemoveQueueCommand", "ReportCommand", "ResetCommand", "RulesCommand", "RunCommand", - "RunQueueCommand", "SaveCommand", - "SaveSessionCommand", "SearchMemoryCommand", + "SessionsCommand", "SettingsCommand", "SkillsCommand", "SpawnAgentCommand", diff --git a/cecli/commands/compact.py b/cecli/commands/compact.py index ae91f670900..0d2b73f87f3 100644 --- a/cecli/commands/compact.py +++ b/cecli/commands/compact.py @@ -14,7 +14,7 @@ async def execute(cls, io, coder, args, **kwargs): # CLI-71: compaction is a command, so run_one() returns early after it. # Set the drain flag so any queued prompts process automatically once - # compaction finishes, instead of stalling until a manual /run-queue. + # compaction finishes, instead of stalling until a manual /queue run. coder._drain_queue_after_command = True @classmethod diff --git a/cecli/commands/core.py b/cecli/commands/core.py index acbd9dde093..29f55332a4f 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -16,7 +16,14 @@ class Commands: # Commands that receive their full argument text for completion (nested # hosts and subcommand commands whose names take an argument). - FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue", "/workflows", "/mcp", "/skills") + FULL_ARGS_COMPLETION_COMMANDS = ( + "/queue", + "/workflows", + "/mcp", + "/skills", + "/sessions", + "/hooks", + ) def _get_coder(self): """Return coder via weak reference, or None if collected.""" @@ -94,7 +101,7 @@ def __init__( self.last_command_show_notification = True # Commands that should NOT trigger auto-processing of the queue - self._MANAGEMENT_COMMANDS = {"queue", "list-queue", "remove-queue", "run-queue"} + self._MANAGEMENT_COMMANDS = {"queue"} def _load_custom_commands(self, custom_commands): """ diff --git a/cecli/commands/hooks.py b/cecli/commands/hooks.py index 5bb3ad5d46a..a2a75117be5 100644 --- a/cecli/commands/hooks.py +++ b/cecli/commands/hooks.py @@ -1,117 +1,93 @@ -import argparse +"""/hooks — list, enable and disable hooks. + +Subcommands: list, load, remove. The ``list`` flags (``--type``, +``--enabled-only``, ``--disabled-only``) may also be passed directly to +``/hooks`` for backwards compatibility. +""" + from typing import List from cecli.commands.utils.base_command import BaseCommand -from cecli.hooks.service import HookService -from cecli.hooks.types import HookType class HooksCommand(BaseCommand): NORM_NAME = "hooks" - DESCRIPTION = "List all registered hooks by type with their current state" + DESCRIPTION = "List, enable and disable hooks" + + SUBCOMMANDS = ("list", "load", "remove") + NAME_SUBCOMMANDS = ("load", "remove") + HELPER_MODULES = { + "list": "cecli.commands.hooks_helpers.list", + "load": "cecli.commands.hooks_helpers.load", + "remove": "cecli.commands.hooks_helpers.remove", + } @classmethod async def execute(cls, io, coder, args, **kwargs): - """Execute the hooks command with given parameters.""" - # Parse the args string - parsed_args = cls._parse_args(args) - - # Get all hooks grouped by type - hook_manager = HookService.get_manager(coder) - all_hooks = hook_manager.get_all_hooks() - - # Apply type filter if specified - if parsed_args.type: - filtered_hooks = {} - if parsed_args.type in all_hooks: - filtered_hooks[parsed_args.type] = all_hooks[parsed_args.type] - all_hooks = filtered_hooks - - # Display hooks - if not all_hooks: - io.tool_output("No hooks registered") - return 0 - - total_hooks = 0 - total_enabled = 0 - - for hook_type, hooks in sorted(all_hooks.items(), reverse=True): - # Apply state filters - filtered_hooks = [] - for hook in hooks: - if parsed_args.enabled_only and not hook.enabled: - continue - if parsed_args.disabled_only and hook.enabled: - continue - filtered_hooks.append(hook) - - if not filtered_hooks: - continue - - io.tool_output(f"\n{hook_type.upper()} hooks:") - io.tool_output("-" * 40) - - for hook in filtered_hooks: - status = "✓ ENABLED" if hook.enabled else "✗ DISABLED" - io.tool_output(f" {hook.name:30} {status}") - - # Show additional info if available - if hasattr(hook, "description") and hook.description: - io.tool_output(f" Description: {hook.description}") - if hasattr(hook, "priority"): - io.tool_output(f" Priority: {hook.priority}") - - total_hooks += 1 - if hook.enabled: - total_enabled += 1 - - io.tool_output( - f"\nTotal hooks: {total_hooks} ({total_enabled} enabled," - f" {total_hooks - total_enabled} disabled)" - ) - return 0 + """Dispatch a hooks subcommand. - @classmethod - def _parse_args(cls, args_string: str) -> argparse.Namespace: - """Parse command line arguments.""" - parser = argparse.ArgumentParser(prog="/hooks", add_help=False) - parser.add_argument( - "--type", - choices=[t.value for t in HookType], - help="Filter hooks by type (start, on_message, end_message, pre_tool, post_tool, end)", - ) - parser.add_argument("--enabled-only", action="store_true", help="Show only enabled hooks") - parser.add_argument("--disabled-only", action="store_true", help="Show only disabled hooks") - - try: - # Split args string and parse - args_list = args_string.split() - return parser.parse_args(args_list) - except SystemExit: - # argparse will call sys.exit() on error, we need to catch it - return argparse.Namespace(type=None, enabled_only=False, disabled_only=False) + Syntax: + /hooks list [--type T] [--enabled-only|--disabled-only] + — List registered hooks (default) + /hooks load ... — Enable one or more hooks + /hooks remove ... — Disable one or more hooks + """ + raw = (args or "").strip() + parts = raw.split(maxsplit=1) + sub = parts[0].lower() if parts else "list" + rest = parts[1].strip() if len(parts) > 1 else "" - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for hooks command.""" - return [] + if sub.startswith("-"): + return cls.loader("list").cmd_list(io, coder, raw) + + if sub == "list": + return cls.loader("list").cmd_list(io, coder, rest) + + if sub == "load": + return cls.loader("load").cmd_load(io, coder, rest) + + if sub == "remove": + return cls.loader("remove").cmd_remove(io, coder, rest) + + io.tool_error(f"Unknown subcommand '{sub}'. Try one of: {', '.join(cls.SUBCOMMANDS)}") @classmethod def get_help(cls) -> str: """Get help text for the hooks command.""" help_text = super().get_help() help_text += "\nUsage:\n" - help_text += " /hooks # List all hooks\n" - help_text += " /hooks --type pre_tool # List only pre_tool hooks\n" - help_text += " /hooks --enabled-only # List only enabled hooks\n" - help_text += " /hooks --disabled-only # List only disabled hooks\n" + help_text += " /hooks list # List all hooks\n" + help_text += " /hooks list --type pre_tool # List only pre_tool hooks\n" + help_text += " /hooks list --enabled-only # List only enabled hooks\n" + help_text += " /hooks list --disabled-only # List only disabled hooks\n" + help_text += " /hooks load ... # Enable one or more hooks\n" + help_text += " /hooks remove ... # Disable one or more hooks\n" help_text += "\nExamples:\n" help_text += " /hooks\n" - help_text += " /hooks --type start\n" - help_text += " /hooks --enabled-only\n" - help_text += "\nThis command displays all registered hooks grouped by type.\n" - help_text += ( - "Each hook shows its name, current state (enabled/disabled), and additional info.\n" - ) - help_text += "Use /load-hook and /remove-hook to enable or disable specific hooks.\n" + help_text += " /hooks --type start # Legacy flag form\n" + help_text += " /hooks load my_start_hook\n" + help_text += " /hooks remove check_commands\n" return help_text + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Complete the subcommand, then each argument in turn.""" + head = (args or "").lstrip() + + if not head: + return list(cls.SUBCOMMANDS) + + if " " not in head: + return [s for s in cls.SUBCOMMANDS if s.startswith(head.lower())] or list( + cls.SUBCOMMANDS + ) + + sub, _, rest = head.partition(" ") + sub = sub.lower() + if sub not in cls.NAME_SUBCOMMANDS: + return [] + + from cecli.commands.hooks_helpers.common import hook_names + + current = "" if not rest or rest.endswith(" ") else rest.rsplit(" ", 1)[-1] + return [name for name in hook_names(coder) if name.startswith(current)] diff --git a/cecli/commands/hooks_helpers/__init__.py b/cecli/commands/hooks_helpers/__init__.py new file mode 100644 index 00000000000..3fcf6190e2e --- /dev/null +++ b/cecli/commands/hooks_helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers for the ``/hooks`` subcommands.""" diff --git a/cecli/commands/hooks_helpers/common.py b/cecli/commands/hooks_helpers/common.py new file mode 100644 index 00000000000..ae73a6a61e2 --- /dev/null +++ b/cecli/commands/hooks_helpers/common.py @@ -0,0 +1,16 @@ +"""Shared lookups for the ``/hooks`` subcommands.""" + + +def hook_names(coder): + """Return the names of every registered hook.""" + from cecli.hooks.service import HookService + + hook_manager = HookService.get_manager(coder) + all_hooks = hook_manager.get_all_hooks() + + names = [] + for hooks in all_hooks.values(): + for hook in hooks: + names.append(hook.name) + + return names diff --git a/cecli/commands/hooks_helpers/list.py b/cecli/commands/hooks_helpers/list.py new file mode 100644 index 00000000000..74b1ec35d81 --- /dev/null +++ b/cecli/commands/hooks_helpers/list.py @@ -0,0 +1,78 @@ +"""Subcommand helper for ``/hooks list``.""" + +import argparse + +from cecli.hooks.service import HookService +from cecli.hooks.types import HookType + + +def cmd_list(io, coder, args): + """List all registered hooks grouped by type.""" + parsed_args = _parse_args(args) + + hook_manager = HookService.get_manager(coder) + all_hooks = hook_manager.get_all_hooks() + + if parsed_args.type: + filtered_hooks = {} + if parsed_args.type in all_hooks: + filtered_hooks[parsed_args.type] = all_hooks[parsed_args.type] + all_hooks = filtered_hooks + + if not all_hooks: + io.tool_output("No hooks registered") + return 0 + + total_hooks = 0 + total_enabled = 0 + + for hook_type, hooks in sorted(all_hooks.items(), reverse=True): + filtered_hooks = [] + for hook in hooks: + if parsed_args.enabled_only and not hook.enabled: + continue + if parsed_args.disabled_only and hook.enabled: + continue + filtered_hooks.append(hook) + + if not filtered_hooks: + continue + + io.tool_output(f"\n{hook_type.upper()} hooks:") + io.tool_output("-" * 40) + + for hook in filtered_hooks: + status = "✓ ENABLED" if hook.enabled else "✗ DISABLED" + io.tool_output(f" {hook.name:30} {status}") + + if hasattr(hook, "description") and hook.description: + io.tool_output(f" Description: {hook.description}") + if hasattr(hook, "priority"): + io.tool_output(f" Priority: {hook.priority}") + + total_hooks += 1 + if hook.enabled: + total_enabled += 1 + + io.tool_output( + f"\nTotal hooks: {total_hooks} ({total_enabled} enabled," + f" {total_hooks - total_enabled} disabled)" + ) + return 0 + + +def _parse_args(args_string): + """Parse ``list`` flags.""" + parser = argparse.ArgumentParser(prog="/hooks list", add_help=False) + parser.add_argument( + "--type", + choices=[t.value for t in HookType], + help="Filter hooks by type (start, on_message, end_message, pre_tool, post_tool, end)", + ) + parser.add_argument("--enabled-only", action="store_true", help="Show only enabled hooks") + parser.add_argument("--disabled-only", action="store_true", help="Show only disabled hooks") + + try: + return parser.parse_args(args_string.split()) + except SystemExit: + return argparse.Namespace(type=None, enabled_only=False, disabled_only=False) diff --git a/cecli/commands/hooks_helpers/load.py b/cecli/commands/hooks_helpers/load.py new file mode 100644 index 00000000000..e26acdfd524 --- /dev/null +++ b/cecli/commands/hooks_helpers/load.py @@ -0,0 +1,29 @@ +"""Subcommand helper for ``/hooks load``.""" + +from cecli.hooks.service import HookService + + +def cmd_load(io, coder, args): + """Enable one or more hooks by name.""" + if not args.strip(): + io.tool_error("Usage: /hooks load ") + return 1 + + hook_names = args.strip().split() + hook_manager = HookService.get_manager(coder) + errors = 0 + + for hook_name in hook_names: + if not hook_manager.hook_exists(hook_name): + io.tool_error(f"Error: Hook '{hook_name}' not found") + errors += 1 + continue + + success = hook_manager.enable_hook(hook_name) + if success: + io.tool_output(f"Hook '{hook_name}' enabled successfully") + else: + io.tool_error(f"Error: Failed to enable hook '{hook_name}'") + errors += 1 + + return 0 if errors == 0 else 1 diff --git a/cecli/commands/hooks_helpers/remove.py b/cecli/commands/hooks_helpers/remove.py new file mode 100644 index 00000000000..7ad8c07f66d --- /dev/null +++ b/cecli/commands/hooks_helpers/remove.py @@ -0,0 +1,29 @@ +"""Subcommand helper for ``/hooks remove``.""" + +from cecli.hooks.service import HookService + + +def cmd_remove(io, coder, args): + """Disable one or more hooks by name.""" + if not args.strip(): + io.tool_error("Usage: /hooks remove ") + return 1 + + hook_names = args.strip().split() + hook_manager = HookService.get_manager(coder) + errors = 0 + + for hook_name in hook_names: + if not hook_manager.hook_exists(hook_name): + io.tool_error(f"Error: Hook '{hook_name}' not found") + errors += 1 + continue + + success = hook_manager.disable_hook(hook_name) + if success: + io.tool_output(f"Hook '{hook_name}' disabled successfully") + else: + io.tool_error(f"Error: Failed to disable hook '{hook_name}'") + errors += 1 + + return 0 if errors == 0 else 1 diff --git a/cecli/commands/insert_queue.py b/cecli/commands/insert_queue.py deleted file mode 100644 index 50ec8246441..00000000000 --- a/cecli/commands/insert_queue.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Insert-queue command for CLI-33: inserts a prompt at a specific queue position.""" - -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result -from cecli.helpers import command_queue - - -class InsertQueueCommand(BaseCommand): - NORM_NAME = "insert-queue" - DESCRIPTION = "Insert a prompt into the queue at a specific position" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the insert-queue command with given parameters. - - Args: - io: InputOutput instance - coder: Coder instance (may be None for some commands) - args: Command arguments as string (" ") - **kwargs: Additional context - - Returns: - Formatted result string - """ - # Sad path: coder.commands is None - if not coder.commands: - return format_command_result( - io, - cls.NORM_NAME, - "", - error="Command system not available. Cannot insert into queue.", - ) - - # Sad path: missing index or prompt text - parts = (args or "").strip().split(maxsplit=1) - if len(parts) != 2: - return format_command_result( - io, - cls.NORM_NAME, - "", - error="Usage: /insert-queue ", - ) - - # Sad path: non-integer index - try: - index = int(parts[0]) - except ValueError: - return format_command_result( - io, - cls.NORM_NAME, - "", - error=f"Invalid index: '{parts[0]}'. Please provide a number.", - ) - - prompt_text = parts[1].strip() - - # Happy path: insert the prompt - try: - item = command_queue.insert_prompt(coder, prompt_text, index) - io.tool_output(f"Prompt inserted at position {index + 1} (id: {item['id']})") - - return f"Successfully executed {cls.NORM_NAME}." - except ValueError as e: - return format_command_result(io, cls.NORM_NAME, "", error=str(e)) - except RuntimeError as e: - return format_command_result(io, cls.NORM_NAME, "", error=str(e)) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for insert-queue command. - - Args are " "; once the index token and a space - are present, mirror the nested prompt's completions (e.g. a nested - slash command delegates to the original command's completions). - """ - text = args or "" - first_space = text.find(" ") - if first_space == -1: - # Still typing the index; suggest valid positions. - if not coder.commands: - return [] - return [str(i) for i in range(1, command_queue.get_queue_length(coder) + 2)] - return command_queue.nested_command_completions(coder, text[first_space + 1 :]) - - @classmethod - def get_help(cls) -> str: - """Get help text for the insert-queue command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /insert-queue # Insert at a specific position\n" - help_text += "\nDescription:\n" - help_text += " Inserts a prompt into the queue at the given 1-based position.\n" - help_text += " Existing items shift down. Index is clamped to the queue bounds.\n" - help_text += "\nExamples:\n" - help_text += " /insert-queue 1 Review the changes in src/main.py\n" - help_text += " /insert-queue 3 Write unit tests for the new feature\n" - help_text += "\nSee also: /queue, /list-queue, /remove-queue, /run-queue\n" - return help_text diff --git a/cecli/commands/list_queue.py b/cecli/commands/list_queue.py deleted file mode 100644 index fb8d8b7f0ad..00000000000 --- a/cecli/commands/list_queue.py +++ /dev/null @@ -1,70 +0,0 @@ -"""List-queue command for CLI-33: displays all prompts in the processing queue.""" - -import datetime -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result -from cecli.helpers import command_queue - - -class ListQueueCommand(BaseCommand): - NORM_NAME = "list-queue" - DESCRIPTION = "List all prompts currently in the queue" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the list-queue command with given parameters. - - Args: - io: InputOutput instance - coder: Coder instance (may be None for some commands) - args: Command arguments (unused for list-queue) - **kwargs: Additional context - - Returns: - Formatted result string - """ - # Sad path: coder.commands is None - if not coder.commands: - return format_command_result( - io, cls.NORM_NAME, "", error="Command system not available. Cannot list queue." - ) - - queue = command_queue.list_queue(coder) - - # Sad path: empty queue - if not queue: - io.tool_output("Queue is empty.") - return f"Successfully executed {cls.NORM_NAME}." - - # Happy path: display numbered list - lines = [] - for i, item in enumerate(queue, start=1): - text = item["text"] - display_text = text[:80] + "..." if len(text) > 80 else text - ts = datetime.datetime.fromtimestamp(item["timestamp"]).strftime("%H:%M:%S") - lines.append(f"[{i}] {display_text} ({ts})") - - io.tool_output("\n".join(lines)) - return f"Successfully executed {cls.NORM_NAME}." - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for list-queue command.""" - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the list-queue command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /list-queue # Display all queued prompts\n" - help_text += "\nDescription:\n" - help_text += " Displays a numbered list of all prompts currently in the queue,\n" - help_text += " showing each prompt's position, text (truncated to 80 chars),\n" - help_text += " and the time it was queued.\n" - help_text += "\nExamples:\n" - help_text += " /list-queue # Shows all queued prompts\n" - help_text += "\nSee also: /queue, /insert-queue, /remove-queue, /run-queue\n" - return help_text diff --git a/cecli/commands/list_sessions.py b/cecli/commands/list_sessions.py deleted file mode 100644 index 84a5f0da718..00000000000 --- a/cecli/commands/list_sessions.py +++ /dev/null @@ -1,56 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class ListSessionsCommand(BaseCommand): - NORM_NAME = "list-sessions" - DESCRIPTION = "List all saved sessions in .cecli/sessions/" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the list-sessions command with given parameters.""" - from cecli.helpers import sessions - - session_manager = sessions.SessionManager(coder, io) - sessions_list = session_manager.list_sessions() - - if not sessions_list: - io.tool_output("No saved sessions found.") - return format_command_result(io, "list-sessions", "No saved sessions found") - - io.tool_output("Saved sessions:") - for session_info in sessions_list: - io.tool_output( - f" {session_info['name']} (model: {session_info['model']}, " - f"format: {session_info['edit_format']}, " - f"{session_info['num_messages']} messages, {session_info['num_files']} files)" - ) - - return format_command_result( - io, "list-sessions", f"Listed {len(sessions_list)} saved sessions" - ) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for list-sessions command.""" - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the list-sessions command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /list-sessions # List all saved sessions\n" - help_text += ( - "\nThis command lists all saved chat sessions in the .cecli/sessions/ directory.\n" - ) - help_text += ( - "Each session shows the name, model, edit format, number of messages, and number of" - " files.\n" - ) - help_text += ( - "Use /save-session to save a session and /load-session to load a saved session.\n" - ) - return help_text diff --git a/cecli/commands/load_hook.py b/cecli/commands/load_hook.py deleted file mode 100644 index cd3cc1c25c3..00000000000 --- a/cecli/commands/load_hook.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.hooks.service import HookService - - -class LoadHookCommand(BaseCommand): - NORM_NAME = "load-hook" - DESCRIPTION = "Enable a specific hook by name" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the load-hook command with given parameters.""" - # Get hook name from args string - if not args.strip(): - io.tool_error("Usage: /load-hook ") - return 1 - hook_names = args.strip().split() - hook_manager = HookService.get_manager(coder) - results = [] - errors = 0 - - for hook_name in hook_names: - if not hook_manager.hook_exists(hook_name): - io.tool_error(f"Error: Hook '{hook_name}' not found") - results.append(f"Hook '{hook_name}' not found") - errors += 1 - continue - - success = hook_manager.enable_hook(hook_name) - if success: - io.tool_output(f"Hook '{hook_name}' enabled successfully") - results.append(f"Hook '{hook_name}' enabled") - else: - io.tool_error(f"Error: Failed to enable hook '{hook_name}'") - results.append(f"Failed to enable hook '{hook_name}'") - errors += 1 - - return 0 if errors == 0 else 1 - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for load-hook command.""" - hook_manager = HookService.get_manager(coder) - all_hooks = hook_manager.get_all_hooks() - - # Get all hook names - hook_names = [] - for hooks in all_hooks.values(): - for hook in hooks: - hook_names.append(hook.name) - - # Filter based on current args - current_arg = args.strip() - if current_arg: - return [name for name in hook_names if name.startswith(current_arg)] - else: - return hook_names - - @classmethod - def get_help(cls) -> str: - """Get help text for the load-hook command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /load-hook ... # Enable one or more hooks\n" - help_text += "\nExamples:\n" - help_text += " /load-hook my_start_hook\n" - help_text += " /load-hook check_commands my_start_hook\n" - help_text += "\nThis command enables one or more hooks that were previously disabled.\n" - help_text += "Use /hooks to see all available hooks and their current state.\n" - return help_text diff --git a/cecli/commands/load_session.py b/cecli/commands/load_session.py deleted file mode 100644 index f9910845795..00000000000 --- a/cecli/commands/load_session.py +++ /dev/null @@ -1,51 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class LoadSessionCommand(BaseCommand): - NORM_NAME = "load-session" - DESCRIPTION = "Load a saved session by name or file path" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the load-session command with given parameters.""" - if not args.strip(): - io.tool_output("Usage: /load-session ") - return format_command_result(io, "load-session", "No session name provided") - - from cecli.helpers import sessions - - session_manager = sessions.SessionManager(coder, io) - await session_manager.load_session(args.strip()) - - return format_command_result(io, "load-session", f"Loaded session: {args.strip()}") - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for load-session command.""" - # Return available session names for completion - from cecli.helpers import sessions - - session_manager = sessions.SessionManager(coder, io) - sessions_list = session_manager.list_sessions() - return [session_info["name"] for session_info in sessions_list] - - @classmethod - def get_help(cls) -> str: - """Get help text for the load-session command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /load-session # Load a saved session\n" - help_text += "\nExamples:\n" - help_text += " /load-session my-feature # Load session 'my-feature'\n" - help_text += " /load-session bug-fix # Load session 'bug-fix'\n" - help_text += ( - "\nSessions are loaded from the .cecli/sessions/ directory. Loading a session" - " also restores any sub-agents saved with it.\n" - ) - help_text += ( - "Use /list-sessions to see saved sessions and /save-session to save a session.\n" - ) - return help_text diff --git a/cecli/commands/queue.py b/cecli/commands/queue.py index 62f02a5f044..4a288e8157b 100644 --- a/cecli/commands/queue.py +++ b/cecli/commands/queue.py @@ -1,95 +1,144 @@ -"""Queue command for CLI-33: adds a prompt to the processing queue.""" +"""/queue — queue prompts and manage the processing queue. + +Subcommands: add, insert, list, remove, run. Bare text after ``/queue`` is +treated as ``add`` for backwards compatibility. +""" from typing import List from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result -from cecli.helpers import command_queue class QueueCommand(BaseCommand): NORM_NAME = "queue" - DESCRIPTION = "Queue a prompt for processing after current tasks complete" + DESCRIPTION = "Queue a prompt or manage the processing queue" + + SUBCOMMANDS = ("add", "insert", "list", "remove", "run") + NAME_SUBCOMMANDS = () + HELPER_MODULES = { + "add": "cecli.commands.queue_helpers.add", + "insert": "cecli.commands.queue_helpers.insert", + "list": "cecli.commands.queue_helpers.list", + "remove": "cecli.commands.queue_helpers.remove", + "run": "cecli.commands.queue_helpers.run", + } @classmethod async def execute(cls, io, coder, args, **kwargs): - """Execute the queue command with given parameters. + """Dispatch a queue subcommand. - Args: - io: InputOutput instance - coder: Coder instance (may be None for some commands) - args: Command arguments as string (the prompt text to queue) - **kwargs: Additional context - - Returns: - Formatted result string + Syntax: + /queue add — Queue a prompt + /queue — Legacy shorthand for add + /queue insert — Insert at a 1-based position + /queue list — Show queued prompts + /queue remove |* — Remove one prompt or clear the queue + /queue run — Process queued prompts now """ - # Sad path: coder.commands is None - if not coder.commands: - return format_command_result( - io, cls.NORM_NAME, "", error="Command system not available. Cannot queue prompts." - ) + raw = (args or "").strip() + parts = raw.split(maxsplit=1) + sub = parts[0].lower() if parts else "" + rest = parts[1].strip() if len(parts) > 1 else "" - # Sad path: no args (empty prompt text) - if not args or not args.strip(): - return format_command_result( - io, - cls.NORM_NAME, - "", - error=( - "Usage: /queue - Add a prompt to the queue " - "for processing after current tasks complete." - ), - ) + if sub == "add": + return cls.loader("add").cmd_add(io, coder, rest) - prompt_text = args.strip() + if sub == "insert": + return cls.loader("insert").cmd_insert(io, coder, rest) - # Sad path: prompt exceeds 10000 characters - if len(prompt_text) > 10000: - return format_command_result( - io, - cls.NORM_NAME, - "", - error=f"Prompt exceeds maximum length of 10000 characters " - f"(got {len(prompt_text)}).", - ) + if sub == "list": + return cls.loader("list").cmd_list(io, coder, rest) + + if sub == "remove": + return cls.loader("remove").cmd_remove(io, coder, rest) - # Happy path: enqueue the prompt - try: - item = command_queue.enqueue_prompt(coder, prompt_text) - position = command_queue.get_queue_length(coder) - io.tool_output(f"Prompt queued at position {position} (id: {item['id']})") + if sub == "run": + return await cls.loader("run").cmd_run(io, coder, rest) - return f"Successfully executed {cls.NORM_NAME}." - except ValueError as e: - return format_command_result(io, cls.NORM_NAME, "", error=str(e)) - except RuntimeError as e: - return format_command_result(io, cls.NORM_NAME, "", error=str(e)) + return cls.loader("add").cmd_add(io, coder, raw) @classmethod def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for queue command. + """Complete the subcommand, then the current argument token. - When the queued prompt starts with a slash command, mirror the - original command's completions (e.g. /queue /add offers files). + ``add`` and the prompt portion of ``insert`` delegate to nested slash + command completion so ``/queue add /ad`` behaves like ``/ad``. """ - return command_queue.nested_command_completions(coder, args) + from cecli.helpers import command_queue + + head = (args or "").lstrip() + + if not head: + return list(cls.SUBCOMMANDS) + + if head.startswith("/"): + return command_queue.nested_command_completions(coder, head) + + if " " not in head: + return [s for s in cls.SUBCOMMANDS if s.startswith(head.lower())] or list( + cls.SUBCOMMANDS + ) + + sub, _, rest = head.partition(" ") + sub = sub.lower() + if sub not in cls.SUBCOMMANDS: + # Legacy shorthand: the whole argument string is an add prompt. + return command_queue.nested_command_completions(coder, head) + + if sub == "add": + return command_queue.nested_command_completions(coder, rest) + + if sub == "insert": + first_space = rest.find(" ") + if first_space == -1: + return cls._index_completions(coder) + + return command_queue.nested_command_completions(coder, rest[first_space + 1 :]) + + if sub == "remove": + return cls._remove_completions(coder) + + return [] @classmethod def get_help(cls) -> str: """Get help text for the queue command.""" help_text = super().get_help() help_text += "\nUsage:\n" - help_text += " /queue # Queue a prompt for later processing\n" + help_text += " /queue # Queue a prompt for later processing\n" + help_text += " /queue add # Same as above, explicit form\n" + help_text += " /queue insert # Insert at a 1-based position\n" + help_text += " /queue list # Display all queued prompts\n" + help_text += " /queue remove # Remove the prompt at an index\n" + help_text += " /queue remove * # Clear the entire queue\n" + help_text += " /queue run # Process all queued prompts now\n" help_text += "\nDescription:\n" - help_text += " Adds a prompt to an in-memory FIFO queue. Queued prompts are\n" - help_text += " processed sequentially after the current command completes.\n" - help_text += "\nConstraints:\n" - help_text += " - Maximum prompt length: 10,000 characters\n" - help_text += " - Maximum queue size: 100 items\n" - help_text += " - Queue is in-memory only (lost on session restart)\n" + help_text += " Queued prompts are processed sequentially after the current command\n" + help_text += " completes. The queue is in-memory only (lost on session restart).\n" help_text += "\nExamples:\n" help_text += " /queue Review the changes in src/main.py\n" - help_text += " /queue Write unit tests for the new feature\n" - help_text += "\nSee also: /insert-queue, /list-queue, /remove-queue, /run-queue\n" + help_text += " /queue insert 1 Write tests for the new feature\n" + help_text += " /queue remove 2\n" return help_text + + @classmethod + def _index_completions(cls, coder) -> List[str]: + """Suggest valid 1-based insertion positions.""" + from cecli.helpers import command_queue + + if not coder.commands: + return [] + + return [str(i) for i in range(1, command_queue.get_queue_length(coder) + 2)] + + @classmethod + def _remove_completions(cls, coder) -> List[str]: + """Suggest queue indices plus the clear-all wildcard.""" + from cecli.helpers import command_queue + + if not coder.commands: + return [] + + completions = [str(i) for i in range(1, command_queue.get_queue_length(coder) + 1)] + completions.append("*") + return completions diff --git a/cecli/commands/queue_helpers/__init__.py b/cecli/commands/queue_helpers/__init__.py new file mode 100644 index 00000000000..e04ec5d0130 --- /dev/null +++ b/cecli/commands/queue_helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers for the ``/queue`` subcommands.""" diff --git a/cecli/commands/queue_helpers/add.py b/cecli/commands/queue_helpers/add.py new file mode 100644 index 00000000000..a2523a1f996 --- /dev/null +++ b/cecli/commands/queue_helpers/add.py @@ -0,0 +1,54 @@ +"""Subcommand helper for ``/queue add``.""" + +from cecli.commands.utils.helpers import format_command_result +from cecli.helpers import command_queue + + +def cmd_add(io, coder, args): + """Add a prompt to the processing queue. + + Args: + io: InputOutput instance + coder: Coder instance (may be None for some commands) + args: Prompt text to queue + + Returns: + Formatted result string + """ + if not coder.commands: + return format_command_result( + io, "queue", "", error="Command system not available. Cannot queue prompts." + ) + + if not args or not args.strip(): + return format_command_result( + io, + "queue", + "", + error=( + "Usage: /queue - Add a prompt to the queue " + "for processing after current tasks complete." + ), + ) + + prompt_text = args.strip() + + if len(prompt_text) > 10000: + return format_command_result( + io, + "queue", + "", + error=f"Prompt exceeds maximum length of 10000 characters " + f"(got {len(prompt_text)}).", + ) + + try: + item = command_queue.enqueue_prompt(coder, prompt_text) + position = command_queue.get_queue_length(coder) + io.tool_output(f"Prompt queued at position {position} (id: {item['id']})") + + return "Successfully executed queue." + except ValueError as e: + return format_command_result(io, "queue", "", error=str(e)) + except RuntimeError as e: + return format_command_result(io, "queue", "", error=str(e)) diff --git a/cecli/commands/queue_helpers/insert.py b/cecli/commands/queue_helpers/insert.py new file mode 100644 index 00000000000..b98cb579b8b --- /dev/null +++ b/cecli/commands/queue_helpers/insert.py @@ -0,0 +1,52 @@ +"""Subcommand helper for ``/queue insert``.""" + +from cecli.commands.utils.helpers import format_command_result +from cecli.helpers import command_queue + + +def cmd_insert(io, coder, args): + """Insert a prompt into the queue at a specific position. + + Args: + io: InputOutput instance + coder: Coder instance (may be None for some commands) + args: ``" "`` + + Returns: + Formatted result string + """ + if not coder.commands: + return format_command_result( + io, + "insert-queue", + "", + error="Command system not available. Cannot insert into queue.", + ) + + parts = (args or "").strip().split(maxsplit=1) + if len(parts) != 2: + return format_command_result( + io, "insert-queue", "", error="Usage: /queue insert " + ) + + try: + index = int(parts[0]) + except ValueError: + return format_command_result( + io, + "insert-queue", + "", + error=f"Invalid index: '{parts[0]}'. Please provide a number.", + ) + + prompt_text = parts[1].strip() + + try: + item = command_queue.insert_prompt(coder, prompt_text, index) + io.tool_output(f"Prompt inserted at position {index + 1} (id: {item['id']})") + + return "Successfully executed insert-queue." + except ValueError as e: + return format_command_result(io, "insert-queue", "", error=str(e)) + except RuntimeError as e: + return format_command_result(io, "insert-queue", "", error=str(e)) diff --git a/cecli/commands/queue_helpers/list.py b/cecli/commands/queue_helpers/list.py new file mode 100644 index 00000000000..14497feced1 --- /dev/null +++ b/cecli/commands/queue_helpers/list.py @@ -0,0 +1,33 @@ +"""Subcommand helper for ``/queue list``.""" + +import datetime + +from cecli.commands.utils.helpers import format_command_result +from cecli.helpers import command_queue + + +def cmd_list(io, coder, args): + """Display all prompts currently in the queue.""" + if not coder.commands: + return format_command_result( + io, + "list-queue", + "", + error="Command system not available. Cannot list queue.", + ) + + queue = command_queue.list_queue(coder) + + if not queue: + io.tool_output("Queue is empty.") + return "Successfully executed list-queue." + + lines = [] + for i, item in enumerate(queue, start=1): + text = item["text"] + display_text = text[:80] + "..." if len(text) > 80 else text + ts = datetime.datetime.fromtimestamp(item["timestamp"]).strftime("%H:%M:%S") + lines.append(f"[{i}] {display_text} ({ts})") + + io.tool_output("\n".join(lines)) + return "Successfully executed list-queue." diff --git a/cecli/commands/queue_helpers/remove.py b/cecli/commands/queue_helpers/remove.py new file mode 100644 index 00000000000..66567301404 --- /dev/null +++ b/cecli/commands/queue_helpers/remove.py @@ -0,0 +1,63 @@ +"""Subcommand helper for ``/queue remove``.""" + +from cecli.commands.utils.helpers import format_command_result +from cecli.helpers import command_queue + + +def cmd_remove(io, coder, args): + """Remove a prompt from the queue by index, or ``*`` to clear all.""" + if not coder.commands: + return format_command_result( + io, + "remove-queue", + "", + error="Command system not available. Cannot remove from queue.", + ) + + if command_queue.get_queue_length(coder) == 0: + return format_command_result( + io, "remove-queue", "", error="Queue is empty. Nothing to remove." + ) + + if args and args.strip() == "*": + items = command_queue.clear_queue(coder) + count = len(items) + io.tool_output(f"Removed all {count} queued prompt(s).") + return "Successfully executed remove-queue." + + if args and args.strip(): + try: + index = int(args.strip()) - 1 + except ValueError: + return format_command_result( + io, + "remove-queue", + "", + error=f"Invalid index: '{args.strip()}'. Please provide a number or '*'.", + ) + + item = command_queue.remove_from_queue(coder, index) + if item is None: + queue_len = command_queue.get_queue_length(coder) + return format_command_result( + io, + "remove-queue", + "", + error=f"Index {args.strip()} is out of range. Queue has {queue_len} item(s).", + ) + + io.tool_output(f"Removed: {item['text'][:80]}") + return "Successfully executed remove-queue." + + queue = command_queue.list_queue(coder) + io.tool_output("Queued prompts:") + for i, item in enumerate(queue, 1): + text = item["text"][:80] + if len(item["text"]) > 80: + text += "..." + io.tool_output(f" [{i}] {text}") + + io.tool_output("\nEnter index to remove, '*' to clear all, or press Enter to cancel:") + return format_command_result( + io, "remove-queue", "Usage: /queue remove | /queue remove *" + ) diff --git a/cecli/commands/queue_helpers/run.py b/cecli/commands/queue_helpers/run.py new file mode 100644 index 00000000000..12e3629c77f --- /dev/null +++ b/cecli/commands/queue_helpers/run.py @@ -0,0 +1,32 @@ +"""Subcommand helper for ``/queue run``.""" + +import asyncio + +from cecli.commands.utils.helpers import format_command_result +from cecli.helpers import command_queue +from cecli.signals import SwitchCoderSignal + + +async def cmd_run(io, coder, args): + """Process queued prompts in order.""" + if not coder.commands: + return format_command_result( + io, "run-queue", "", error="Command system not available. Cannot run queue." + ) + + target = command_queue.get_active_coder(coder) + + if command_queue.get_queue_length(target) == 0: + io.tool_output("Queue is empty. Use /queue to add prompts first.") + return "Successfully executed run-queue." + + try: + await target.process_prompt_queue() + return "Successfully executed run-queue." + except (KeyboardInterrupt, asyncio.CancelledError): + io.tool_output("Queue processing paused. Use /queue run to resume.") + return "Successfully executed run-queue." + except (SwitchCoderSignal, SystemExit): + raise + except Exception as e: + return format_command_result(io, "run-queue", "", error=str(e)) diff --git a/cecli/commands/remove_hook.py b/cecli/commands/remove_hook.py deleted file mode 100644 index 1eb5056687d..00000000000 --- a/cecli/commands/remove_hook.py +++ /dev/null @@ -1,72 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.hooks.service import HookService - - -class RemoveHookCommand(BaseCommand): - NORM_NAME = "remove-hook" - DESCRIPTION = "Disable a specific hook by name" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the remove-hook command with given parameters.""" - # Get hook name from args string - if not args.strip(): - io.tool_error("Usage: /remove-hook ") - return 1 - hook_names = args.strip().split() - hook_manager = HookService.get_manager(coder) - results = [] - errors = 0 - - for hook_name in hook_names: - if not hook_manager.hook_exists(hook_name): - io.tool_error(f"Error: Hook '{hook_name}' not found") - results.append(f"Hook '{hook_name}' not found") - errors += 1 - continue - - success = hook_manager.disable_hook(hook_name) - if success: - io.tool_output(f"Hook '{hook_name}' disabled successfully") - results.append(f"Hook '{hook_name}' disabled") - else: - io.tool_error(f"Error: Failed to disable hook '{hook_name}'") - results.append(f"Failed to disable hook '{hook_name}'") - errors += 1 - - return 0 if errors == 0 else 1 - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for remove-hook command.""" - hook_manager = HookService.get_manager(coder) - all_hooks = hook_manager.get_all_hooks() - - # Get all hook names - hook_names = [] - for hooks in all_hooks.values(): - for hook in hooks: - hook_names.append(hook.name) - - # Filter based on current args - current_arg = args.strip() - if current_arg: - return [name for name in hook_names if name.startswith(current_arg)] - else: - return hook_names - - @classmethod - def get_help(cls) -> str: - """Get help text for the remove-hook command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /remove-hook ... # Disable one or more hooks\n" - help_text += "\nExamples:\n" - help_text += " /remove-hook my_start_hook\n" - help_text += " /remove-hook check_commands my_start_hook\n" - help_text += "\nThis command disables a hook without removing it from the registry.\n" - help_text += "Use /load-hook to re-enable it later.\n" - help_text += "Use /hooks to see all available hooks and their current state.\n" - return help_text diff --git a/cecli/commands/remove_queue.py b/cecli/commands/remove_queue.py deleted file mode 100644 index 21fca516e41..00000000000 --- a/cecli/commands/remove_queue.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Remove-queue command for CLI-33: removes prompts from the processing queue.""" - -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result -from cecli.helpers import command_queue - - -class RemoveQueueCommand(BaseCommand): - NORM_NAME = "remove-queue" - DESCRIPTION = "Remove a prompt from the queue by index, or '*' to clear all" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the remove-queue command with given parameters. - - Args: - io: InputOutput instance - coder: Coder instance (may be None for some commands) - args: Command arguments (index number, '*', or empty for interactive) - **kwargs: Additional context - - Returns: - Formatted result string - """ - # Sad path: coder.commands is None - if not coder.commands: - return format_command_result( - io, - cls.NORM_NAME, - "", - error="Command system not available. Cannot remove from queue.", - ) - - # Sad path: empty queue - if command_queue.get_queue_length(coder) == 0: - return format_command_result( - io, cls.NORM_NAME, "", error="Queue is empty. Nothing to remove." - ) - - # Handle wildcard: clear entire queue - if args and args.strip() == "*": - items = command_queue.clear_queue(coder) - count = len(items) - io.tool_output(f"Removed all {count} queued prompt(s).") - return f"Successfully executed {cls.NORM_NAME}." - - # Handle specific index - if args and args.strip(): - try: - index = int(args.strip()) - 1 # Convert to 0-based - except ValueError: - return format_command_result( - io, - cls.NORM_NAME, - "", - error=f"Invalid index: '{args.strip()}'. Please provide a number or '*'.", - ) - - item = command_queue.remove_from_queue(coder, index) - if item is None: - queue_len = command_queue.get_queue_length(coder) - return format_command_result( - io, - cls.NORM_NAME, - "", - error=f"Index {args.strip()} is out of range. Queue has {queue_len} item(s).", - ) - - io.tool_output(f"Removed: {item['text'][:80]}") - return f"Successfully executed {cls.NORM_NAME}." - - # Interactive mode: no args provided - queue = command_queue.list_queue(coder) - io.tool_output("Queued prompts:") - for i, item in enumerate(queue, 1): - text = item["text"][:80] - if len(item["text"]) > 80: - text += "..." - io.tool_output(f" [{i}] {text}") - - io.tool_output("\nEnter index to remove, '*' to clear all, or press Enter to cancel:") - # In non-interactive mode, just show usage - return format_command_result( - io, cls.NORM_NAME, "Usage: /remove-queue | /remove-queue *" - ) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for remove-queue command.""" - if not coder.commands: - return [] - - queue_len = command_queue.get_queue_length(coder) - completions = [str(i) for i in range(1, queue_len + 1)] - completions.append("*") - return completions - - @classmethod - def get_help(cls) -> str: - """Get help text for the remove-queue command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /remove-queue # Remove prompt at given index\n" - help_text += " /remove-queue * # Clear the entire queue\n" - help_text += " /remove-queue # Interactive mode (shows list, prompts for index)\n" - help_text += "\nDescription:\n" - help_text += " Removes a prompt from the in-memory queue by its index number.\n" - help_text += " Use '*' to clear all queued prompts at once.\n" - help_text += ( - " Without arguments, displays the queue and prompts for interactive selection.\n" - ) - help_text += "\nExamples:\n" - help_text += " /remove-queue 1 # Remove the first queued prompt\n" - help_text += " /remove-queue * # Clear the entire queue\n" - help_text += " /remove-queue # Interactive mode\n" - help_text += "\nSee also: /queue, /insert-queue, /list-queue, /run-queue\n" - return help_text diff --git a/cecli/commands/run_queue.py b/cecli/commands/run_queue.py deleted file mode 100644 index bc9522b4c6b..00000000000 --- a/cecli/commands/run_queue.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Run-queue command for CLI-33: processes queued prompts on demand. - -Primary use: after cancelling an in-flight response (Ctrl+C), the in-flight -prompt is re-queued at the front. /run-queue resumes processing the queue. -""" - -import asyncio -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result -from cecli.helpers import command_queue -from cecli.signals import SwitchCoderSignal - - -class RunQueueCommand(BaseCommand): - NORM_NAME = "run-queue" - DESCRIPTION = "Process queued prompts in order" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the run-queue command with given parameters. - - Args: - io: InputOutput instance - coder: Coder instance (may be None for some commands) - args: Command arguments (unused for run-queue) - **kwargs: Additional context - - Returns: - Formatted result string - """ - # Sad path: coder.commands is None - if not coder.commands: - return format_command_result( - io, cls.NORM_NAME, "", error="Command system not available. Cannot run queue." - ) - - # Resolve the target coder (sub-agent aware: use the foreground agent's queue) - target = command_queue.get_active_coder(coder) - - # Sad path: empty queue - if command_queue.get_queue_length(target) == 0: - io.tool_output("Queue is empty. Use /queue to add prompts first.") - return f"Successfully executed {cls.NORM_NAME}." - - # Happy path: drain the queue - try: - await target.process_prompt_queue() - return f"Successfully executed {cls.NORM_NAME}." - except (KeyboardInterrupt, asyncio.CancelledError): - # The interrupted item was re-queued; let the user know the queue paused. - io.tool_output("Queue processing paused. Use /run-queue to resume.") - return f"Successfully executed {cls.NORM_NAME}." - except (SwitchCoderSignal, SystemExit): - raise - except Exception as e: - return format_command_result(io, cls.NORM_NAME, "", error=str(e)) - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for run-queue command.""" - return [] - - @classmethod - def get_help(cls) -> str: - """Get help text for the run-queue command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /run-queue # Process all queued prompts in FIFO order\n" - help_text += "\nDescription:\n" - help_text += " Processes every prompt currently in the queue, in order.\n" - help_text += " Queued prompts normally run automatically after the current\n" - help_text += " response finishes. Use /run-queue to resume a queue that was\n" - help_text += " paused (e.g. after cancelling an in-flight response with Ctrl+C).\n" - help_text += "\nExamples:\n" - help_text += " /run-queue # Drains the queue, processing each prompt in order\n" - help_text += "\nSee also: /queue, /insert-queue, /list-queue, /remove-queue\n" - return help_text diff --git a/cecli/commands/save_session.py b/cecli/commands/save_session.py deleted file mode 100644 index cf32180ec96..00000000000 --- a/cecli/commands/save_session.py +++ /dev/null @@ -1,54 +0,0 @@ -from typing import List - -from cecli.commands.utils.base_command import BaseCommand -from cecli.commands.utils.helpers import format_command_result - - -class SaveSessionCommand(BaseCommand): - NORM_NAME = "save-session" - DESCRIPTION = "Save the current chat session to a named file in .cecli/sessions/" - - @classmethod - async def execute(cls, io, coder, args, **kwargs): - """Execute the save-session command with given parameters.""" - if not args.strip(): - io.tool_error("Please provide a session name to save.") - return format_command_result(io, "save-session", "No session name provided") - - from cecli.helpers import sessions - - session_manager = sessions.SessionManager(coder, io) - session_manager.save_session(args.strip()) - - return format_command_result(io, "save-session", f"Saved session: {args.strip()}") - - @classmethod - def get_completions(cls, io, coder, args) -> List[str]: - """Get completion options for save-session command.""" - # Return existing session names for completion to prevent accidental overwrites - from cecli.helpers import sessions - - session_manager = sessions.SessionManager(coder, io) - sessions_list = session_manager.list_sessions() - return [session_info["name"] for session_info in sessions_list] - - @classmethod - def get_help(cls) -> str: - """Get help text for the save-session command.""" - help_text = super().get_help() - help_text += "\nUsage:\n" - help_text += " /save-session # Save current chat session\n" - help_text += "\nExamples:\n" - help_text += " /save-session my-feature # Save session as 'my-feature'\n" - help_text += " /save-session bug-fix # Save session as 'bug-fix'\n" - help_text += "\nSessions are saved in the .cecli/sessions/ directory as JSON files.\n" - help_text += ( - "When the session has sub-agents it is saved as a folder holding the primary" - " and each sub-agent payload.\n" - ) - help_text += "Use /list-sessions to see saved sessions and /load-session to load them.\n" - help_text += ( - "\nNote: Existing session names will be shown for tab completion to help prevent" - " accidental overwrites.\n" - ) - return help_text diff --git a/cecli/commands/sessions.py b/cecli/commands/sessions.py new file mode 100644 index 00000000000..889ad5206e0 --- /dev/null +++ b/cecli/commands/sessions.py @@ -0,0 +1,85 @@ +"""/sessions — list, load and save chat sessions. + +Subcommands are dispatched to helper functions in +``cecli.commands.sessions_helpers``. +""" + +from typing import List + +from cecli.commands.utils.base_command import BaseCommand + + +class SessionsCommand(BaseCommand): + NORM_NAME = "sessions" + DESCRIPTION = "List, load and save chat sessions" + + SUBCOMMANDS = ("list", "load", "save") + NAME_SUBCOMMANDS = ("load", "save") + HELPER_MODULES = { + "list": "cecli.commands.sessions_helpers.list", + "load": "cecli.commands.sessions_helpers.load", + "save": "cecli.commands.sessions_helpers.save", + } + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + """Dispatch a sessions subcommand. + + Syntax: + /sessions list — List saved sessions (default) + /sessions load — Load a saved session + /sessions save — Save the current session + """ + parts = (args or "").strip().split(maxsplit=1) + sub = parts[0].lower() if parts else "list" + rest = parts[1].strip() if len(parts) > 1 else "" + + if sub == "list": + return cls.loader("list").cmd_list(io, coder, rest) + + if sub == "load": + return await cls.loader("load").cmd_load(io, coder, rest) + + if sub == "save": + return cls.loader("save").cmd_save(io, coder, rest) + + io.tool_error(f"Unknown subcommand '{sub}'. Try one of: {', '.join(cls.SUBCOMMANDS)}") + + @classmethod + def get_help(cls) -> str: + """Get help text for the sessions command.""" + help_text = super().get_help() + help_text += "\nUsage:\n" + help_text += " /sessions list # List saved sessions\n" + help_text += " /sessions load # Load a saved session\n" + help_text += " /sessions save # Save the current session\n" + help_text += "\nSessions are stored in the .cecli/sessions/ directory. Loading a session\n" + help_text += "also restores any sub-agents saved with it.\n" + help_text += "\nExamples:\n" + help_text += " /sessions list\n" + help_text += " /sessions load my-feature\n" + help_text += " /sessions save bug-fix\n" + return help_text + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Complete the subcommand, then each argument in turn.""" + head = (args or "").lstrip() + + if not head: + return list(cls.SUBCOMMANDS) + + if " " not in head: + return [s for s in cls.SUBCOMMANDS if s.startswith(head.lower())] or list( + cls.SUBCOMMANDS + ) + + sub, _, rest = head.partition(" ") + sub = sub.lower() + if sub not in cls.NAME_SUBCOMMANDS: + return [] + + from cecli.commands.sessions_helpers.common import session_names + + current = "" if not rest or rest.endswith(" ") else rest.rsplit(" ", 1)[-1] + return [name for name in session_names(coder, io) if name.startswith(current)] diff --git a/cecli/commands/sessions_helpers/__init__.py b/cecli/commands/sessions_helpers/__init__.py new file mode 100644 index 00000000000..7020e511abf --- /dev/null +++ b/cecli/commands/sessions_helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers for the ``/sessions`` subcommands.""" diff --git a/cecli/commands/sessions_helpers/common.py b/cecli/commands/sessions_helpers/common.py new file mode 100644 index 00000000000..b8136944265 --- /dev/null +++ b/cecli/commands/sessions_helpers/common.py @@ -0,0 +1,9 @@ +"""Shared lookups for the ``/sessions`` subcommands.""" + + +def session_names(coder, io): + """Return the names of all saved sessions.""" + from cecli.helpers import sessions + + session_manager = sessions.SessionManager(coder, io) + return [session_info["name"] for session_info in session_manager.list_sessions()] diff --git a/cecli/commands/sessions_helpers/list.py b/cecli/commands/sessions_helpers/list.py new file mode 100644 index 00000000000..2b89d42cd2b --- /dev/null +++ b/cecli/commands/sessions_helpers/list.py @@ -0,0 +1,25 @@ +"""Subcommand helper for ``/sessions list``.""" + +from cecli.commands.utils.helpers import format_command_result + + +def cmd_list(io, coder, args): + """List all saved sessions.""" + from cecli.helpers import sessions + + session_manager = sessions.SessionManager(coder, io) + sessions_list = session_manager.list_sessions() + + if not sessions_list: + io.tool_output("No saved sessions found.") + return format_command_result(io, "list-sessions", "No saved sessions found") + + io.tool_output("Saved sessions:") + for session_info in sessions_list: + io.tool_output( + f" {session_info['name']} (model: {session_info['model']}, " + f"format: {session_info['edit_format']}, " + f"{session_info['num_messages']} messages, {session_info['num_files']} files)" + ) + + return format_command_result(io, "list-sessions", f"Listed {len(sessions_list)} saved sessions") diff --git a/cecli/commands/sessions_helpers/load.py b/cecli/commands/sessions_helpers/load.py new file mode 100644 index 00000000000..241536ec660 --- /dev/null +++ b/cecli/commands/sessions_helpers/load.py @@ -0,0 +1,17 @@ +"""Subcommand helper for ``/sessions load``.""" + +from cecli.commands.utils.helpers import format_command_result + + +async def cmd_load(io, coder, args): + """Load a saved session by name or file path.""" + if not args.strip(): + io.tool_output("Usage: /sessions load ") + return format_command_result(io, "load-session", "No session name provided") + + from cecli.helpers import sessions + + session_manager = sessions.SessionManager(coder, io) + await session_manager.load_session(args.strip()) + + return format_command_result(io, "load-session", f"Loaded session: {args.strip()}") diff --git a/cecli/commands/sessions_helpers/save.py b/cecli/commands/sessions_helpers/save.py new file mode 100644 index 00000000000..4a8a4720fac --- /dev/null +++ b/cecli/commands/sessions_helpers/save.py @@ -0,0 +1,17 @@ +"""Subcommand helper for ``/sessions save``.""" + +from cecli.commands.utils.helpers import format_command_result + + +def cmd_save(io, coder, args): + """Save the current chat session under a name.""" + if not args.strip(): + io.tool_error("Please provide a session name to save.") + return format_command_result(io, "save-session", "No session name provided") + + from cecli.helpers import sessions + + session_manager = sessions.SessionManager(coder, io) + session_manager.save_session(args.strip()) + + return format_command_result(io, "save-session", f"Saved session: {args.strip()}") diff --git a/cecli/helpers/command_queue.py b/cecli/helpers/command_queue.py index 91d7ba64c65..ad8969d71c0 100644 --- a/cecli/helpers/command_queue.py +++ b/cecli/helpers/command_queue.py @@ -207,7 +207,7 @@ def nested_command_completions(coder, text: str) -> list: """Completions for a nested slash-command inside queued prompt text (CLI-33). While the inner command is still being typed (no space yet), suggest - matching command names from the registry (e.g. "/sa" -> "/save-session"). + matching command names from the registry (e.g. "/sess" -> "/sessions"). Once the inner command is complete, delegate to the original command's ``get_completions`` so ``/queue /add `` behaves exactly like ``/add ``. """ diff --git a/cecli/helpers/sessions/manager.py b/cecli/helpers/sessions/manager.py index c5e29fe310d..974baacdf31 100644 --- a/cecli/helpers/sessions/manager.py +++ b/cecli/helpers/sessions/manager.py @@ -307,7 +307,7 @@ def _find_session_file(self, session_identifier: str) -> Optional[Path]: return session_file self.io.tool_error(f"Session not found: {session_identifier}") - self.io.tool_output("Use /list-sessions to see available sessions.") + self.io.tool_output("Use /sessions list to see available sessions.") return None diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 0d29c3cf6b5..551ce76eeae 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -936,18 +936,13 @@ def on_input_area_submit(self, message: InputArea.Submit): self._handle_workspace_command(user_input, stripped) return - # Intercept queue management commands (/queue, /list-queue, /remove-queue, - # /insert-queue) to dispatch immediately without a full generation cycle - - # they only modify the active coder's prompt_queue. - if ( - stripped == "/queue" - or stripped.startswith("/queue ") - or stripped == "/list-queue" - or stripped == "/remove-queue" - or stripped.startswith("/remove-queue ") - or stripped == "/insert-queue" - or stripped.startswith("/insert-queue ") - ): + # Intercept queue management commands (/queue add/insert/list/remove) + # to dispatch immediately without a full generation cycle - they only + # modify the active coder's prompt_queue. /queue run is left to the + # normal command path so queue draining behaves like the old /run-queue. + is_queue_command = stripped == "/queue" or stripped.startswith("/queue ") + is_queue_run = stripped == "/queue run" or stripped.startswith("/queue run ") + if is_queue_command and not is_queue_run: self._handle_queue_command(stripped) return @@ -1028,10 +1023,11 @@ def on_input_area_submit(self, message: InputArea.Submit): queues.wake_input_waiters() def _handle_queue_command(self, stripped: str) -> None: - """Dispatch /queue, /list-queue and /remove-queue immediately. + """Dispatch queue subcommands that only mutate ``prompt_queue``. - These commands only mutate the active coder's ``prompt_queue``, so they - are handled here without running a full generation cycle. + These commands are handled here without running a full generation + cycle. ``/queue run`` is not intercepted; it needs the normal command + path so the queue drain can signal correctly. """ from cecli.helpers import command_queue from cecli.helpers.agents.service import AgentService @@ -1044,10 +1040,18 @@ def _handle_queue_command(self, stripped: str) -> None: active_coder = ( AgentService.get_instance(self.worker.coder).foreground_coder or self.worker.coder ) - cmd, _, args = stripped.partition(" ") + _, _, raw_args = stripped.partition(" ") + raw_args = raw_args.strip() + sub, _, args = raw_args.partition(" ") + sub = sub.lower() args = args.strip() - if cmd == "/queue": + if sub not in {"add", "insert", "list", "remove"}: + # Legacy shorthand: everything after /queue is an add prompt. + args = raw_args + sub = "add" + + if sub == "add": if not args: self.show_error("Usage: /queue ") return @@ -1063,7 +1067,7 @@ def _handle_queue_command(self, stripped: str) -> None: f"Prompt queued at position {position} (id: {item['id']})" ) - elif cmd == "/list-queue": + elif sub == "list": items = command_queue.list_queue(active_coder) if not items: self._get_visible_container().add_output("Queue is empty.") @@ -1078,10 +1082,10 @@ def _handle_queue_command(self, stripped: str) -> None: self._get_visible_container().add_output("\n".join(lines)) - elif cmd == "/insert-queue": + elif sub == "insert": parts = args.split(maxsplit=1) if len(parts) != 2: - self.show_error("Usage: /insert-queue ") + self.show_error("Usage: /queue insert ") return try: @@ -1101,9 +1105,9 @@ def _handle_queue_command(self, stripped: str) -> None: f"Prompt inserted at position {index + 1} (id: {item['id']})" ) - elif cmd == "/remove-queue": + elif sub == "remove": if not args: - self.show_error("Usage: /remove-queue ") + self.show_error("Usage: /queue remove ") return if args == "*": @@ -1116,7 +1120,7 @@ def _handle_queue_command(self, stripped: str) -> None: try: index = int(args) except ValueError: - self.show_error("Usage: /remove-queue ") + self.show_error("Usage: /queue remove ") return removed = command_queue.remove_from_queue(active_coder, index - 1) diff --git a/cecli/website/docs/config/hooks.md b/cecli/website/docs/config/hooks.md index 11bb8b3e4be..76d2e7e7db9 100644 --- a/cecli/website/docs/config/hooks.md +++ b/cecli/website/docs/config/hooks.md @@ -334,8 +334,8 @@ The endpoint comes from `--system-one` or the `system-one` key in `.cecli.conf.y You can manage hooks during an active session using the following slash commands: - `/hooks`: List all registered hooks and their status. -- `/load-hook `: Enable a specific hook. -- `/remove-hook `: Disable a specific hook. +- `/hooks load `: Enable a specific hook. +- `/hooks remove `: Disable a specific hook. ## Best Practices diff --git a/cecli/website/docs/usage/commands.md b/cecli/website/docs/usage/commands.md index a290080b8c2..456314c47d4 100644 --- a/cecli/website/docs/usage/commands.md +++ b/cecli/website/docs/usage/commands.md @@ -30,6 +30,7 @@ Cecli supports commands from within the chat, which all start with `/`. | **/git** | Run a git command (output excluded from chat) | | **/help** | Ask questions about cecli | | **/history-search** | Fuzzy search your command history and paste the selected command into the chat. | +| **/hooks** | Manage hooks: `/hooks list`, `/hooks load `, `/hooks remove ` | | **/lint** | Lint and fix in-chat files or all dirty files if none in chat | | **/load** | Load and execute commands from a file | | **/ls** | List all known files and indicate which are included in the chat session | @@ -47,6 +48,7 @@ Cecli supports commands from within the chat, which all start with `/`. | **/reset** | Drop all files and clear the chat history | | **/run** | Run a shell command and optionally add the output to the chat (alias: !) | | **/save** | Save commands to a file that can reconstruct the current chat session's files | +| **/sessions** | Manage sessions: `/sessions list`, `/sessions load `, `/sessions save ` | | **/settings** | Print out the current settings | | **/skills** | Manage skills: `/skills list`, `load`, `include`, `exclude`, `remove`, `import` | | **/switch-agent** | Switch to a specific agent by name | @@ -62,63 +64,63 @@ Cecli supports commands from within the chat, which all start with `/`. ## Prompt Queue Management -The prompt queue is a first-in-first-out (FIFO) queue of prompts attached to the active agent. Queued prompts run after the current response finishes. Starting a new prompt also drains the queue after that prompt completes; use `/run-queue` to start it explicitly. +The prompt queue is a first-in-first-out (FIFO) queue of prompts attached to the active agent. Queued prompts run after the current response finishes. Starting a new prompt also drains the queue after that prompt completes; use `/queue run` to start it explicitly. | Command | Description | |---------|-------------| -| **/queue** | Queue a prompt for processing after current tasks complete | -| **/insert-queue** | Insert a prompt at a specific position in the queue | -| **/list-queue** | List all prompts currently in the queue | -| **/remove-queue** | Remove a prompt from the queue by index, or '*' to clear all | -| **/run-queue** | Process queued prompts immediately | +| **/queue add** | Queue a prompt for processing after current tasks complete | +| **/queue insert** | Insert a prompt at a specific position in the queue | +| **/queue list** | List all prompts currently in the queue | +| **/queue remove** | Remove a prompt from the queue by index, or '*' to clear all | +| **/queue run** | Process queued prompts immediately | -If a response is interrupted (for example with Ctrl+C), the in-flight prompt is put back at the front of the queue. Use `/run-queue` to resume processing. +If a response is interrupted (for example with Ctrl+C), the in-flight prompt is put back at the front of the queue. Use `/queue run` to resume processing. -### `/queue` Command +### `/queue add` Command -**Usage:** `/queue ` +**Usage:** `/queue add `, or the shorthand `/queue ` **Description:** Adds a prompt to the queue for processing after the current response completes. **Examples:** ```bash -/queue "refactor database layer" +/queue add "refactor database layer" /queue "add unit tests for user service" ``` -### `/insert-queue` Command +### `/queue insert` Command -**Usage:** `/insert-queue ` +**Usage:** `/queue insert ` **Description:** Inserts a prompt at a specific 1-based position in the queue. Existing items shift down, and the index is clamped to the queue bounds. **Examples:** ```bash -/insert-queue 1 "add tests for login" -/insert-queue 3 "refactor database layer" +/queue insert 1 "add tests for login" +/queue insert 3 "refactor database layer" ``` -### `/list-queue` Command +### `/queue list` Command -**Usage:** `/list-queue` +**Usage:** `/queue list` **Description:** Lists all queued prompts with their position and queue time. -### `/remove-queue` Command +### `/queue remove` Command -**Usage:** `/remove-queue `, `/remove-queue *` +**Usage:** `/queue remove `, `/queue remove *` **Description:** Removes the prompt at the given 1-based position, or clears the entire queue when `*` is used. -### `/run-queue` Command +### `/queue run` Command -**Usage:** `/run-queue` +**Usage:** `/queue run` **Description:** Processes every queued prompt immediately, in FIFO order. This is mainly useful to resume a queue that was paused by an interruption. **Examples:** ```bash -/run-queue +/queue run ``` diff --git a/cecli/website/docs/usage/sessions.md b/cecli/website/docs/usage/sessions.md index f6473728724..2cc5c494580 100644 --- a/cecli/website/docs/usage/sessions.md +++ b/cecli/website/docs/usage/sessions.md @@ -14,9 +14,22 @@ Cecli provides session management commands that allow you to save, load, and man ## Session Commands -### `/save-session ` +### `/sessions save ` Save the current chat session to a named file in `.cecli/sessions/`. +**Usage:** +``` +/sessions save my-project-session +``` + +**What gets saved:** +- Chat history (both done and current messages) +- All files in the chat (editable, read-only, and read-only stubs) +- Current model and edit format settings +- Auto-commit, auto-lint, and auto-test settings +- Todo list content from `.cecli/run/{date}/{agent id}/todo.txt` +- Session metadata (timestamp, version) + ### Auto-Save and Auto-Load Cecli can automatically save and load sessions using command line options: @@ -38,25 +51,12 @@ auto-load: true When `--auto-save` is enabled, cecli will automatically save your session as 'auto-save' when you exit. When `--auto-load` is enabled, cecli will automatically load the 'auto-save' session on startup if it exists. -**Usage:** -``` -/save-session my-project-session -``` - -**What gets saved:** -- Chat history (both done and current messages) -- All files in the chat (editable, read-only, and read-only stubs) -- Current model and edit format settings -- Auto-commit, auto-lint, and auto-test settings -- Todo list content from `.cecli/run/{date}/{agent id}/todo.txt` -- Session metadata (timestamp, version) - -### `/load-session ` +### `/sessions load ` Load a previously saved session by name or file path. **Usage:** ``` -/load-session my-project-session +/sessions load my-project-session ``` **What gets loaded:** @@ -65,12 +65,12 @@ Load a previously saved session by name or file path. - Preserves all settings and model configurations - Restores the todo list content saved in the session -### `/list-sessions` +### `/sessions list` List all available saved sessions in `.cecli/sessions/`. **Usage:** ``` -/list-sessions +/sessions list ``` **Shows:** @@ -123,25 +123,25 @@ Sessions are stored as JSON files in the `.cecli/sessions/` directory within you # Start working on a project /add src/main.py src/utils.py # ... have a conversation ... -/save-session my-project +/sessions save my-project # Later, continue where you left off -/load-session my-project +/sessions load my-project ``` ### Multiple Contexts ``` # Work on frontend /add src/components/*.jsx src/styles/*.css -/save-session frontend-work +/sessions save frontend-work # Switch to backend /reset /add server/*.py database/*.sql -/save-session backend-work +/sessions save backend-work # Easily switch between contexts -/load-session frontend-work +/sessions load frontend-work ``` ## Best Practices @@ -172,10 +172,10 @@ Or use `--session-key-file` pointing at a file with the same urlsafe-base64 32-b ## Troubleshooting ### Session Not Found -If `/load-session` reports "Session not found": +If `/sessions load` reports "Session not found": - Check that the session file exists in `.cecli/sessions/` - Verify the session name matches exactly -- Use `/list-sessions` to see available sessions +- Use `/sessions list` to see available sessions ### Missing Files If files are reported as missing during loading: @@ -202,10 +202,10 @@ If a session fails to load: # Start a new project session /add package.json src/main.js src/components/ # ... work on the project ... -/save-session react-project +/sessions save react-project # Later, continue working -/load-session react-project +/sessions load react-project # All files and chat history are restored ``` @@ -213,16 +213,16 @@ If a session fails to load: ``` # Include documentation from outside the project /read-only ~/docs/api-reference.md -/save-session project-with-docs +/sessions save project-with-docs ``` ### Multiple Model Sessions ``` # Save session with specific model /model gpt-5 -/save-session gpt5-session +/sessions save gpt5-session # Try different model /model claude-sonnet-4.5 -/save-session claude-session +/sessions save claude-session ``` diff --git a/tests/commands/test_hooks_sessions_commands.py b/tests/commands/test_hooks_sessions_commands.py new file mode 100644 index 00000000000..11967461284 --- /dev/null +++ b/tests/commands/test_hooks_sessions_commands.py @@ -0,0 +1,165 @@ +"""Tests for the /hooks and /sessions dispatcher commands.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from cecli.commands.hooks import HooksCommand +from cecli.commands.sessions import SessionsCommand +from cecli.commands.utils.registry import CommandRegistry + + +def _fake_loader(modules): + return classmethod(lambda cls, sub: modules[sub]) + + +def _recorder(calls, name): + def record(io, coder, args): + calls.append((name, args)) + return name + + return record + + +def _async_recorder(calls, name): + async def record(io, coder, args): + calls.append((name, args)) + return name + + return record + + +class TestHooksDispatch: + @pytest.mark.asyncio + async def test_flags_route_to_list(self, monkeypatch): + calls = [] + modules = {"list": SimpleNamespace(cmd_list=_recorder(calls, "list"))} + monkeypatch.setattr(HooksCommand, "loader", _fake_loader(modules)) + + await HooksCommand.execute(None, None, "--type start") + + assert calls == [("list", "--type start")] + + @pytest.mark.asyncio + async def test_load_and_remove_route_with_args(self, monkeypatch): + calls = [] + modules = { + "load": SimpleNamespace(cmd_load=_recorder(calls, "load")), + "remove": SimpleNamespace(cmd_remove=_recorder(calls, "remove")), + } + monkeypatch.setattr(HooksCommand, "loader", _fake_loader(modules)) + + await HooksCommand.execute(None, None, "load alpha beta") + await HooksCommand.execute(None, None, "remove gamma") + + assert calls == [("load", "alpha beta"), ("remove", "gamma")] + + @pytest.mark.asyncio + async def test_unknown_subcommand_reports_error(self): + io = MagicMock() + + await HooksCommand.execute(io, None, "bogus") + + io.tool_error.assert_called_once() + + +class TestSessionsDispatch: + @pytest.mark.asyncio + async def test_empty_args_defaults_to_list(self, monkeypatch): + calls = [] + modules = {"list": SimpleNamespace(cmd_list=_recorder(calls, "list"))} + monkeypatch.setattr(SessionsCommand, "loader", _fake_loader(modules)) + + await SessionsCommand.execute(None, None, "") + + assert calls == [("list", "")] + + @pytest.mark.asyncio + async def test_load_and_save_route_with_args(self, monkeypatch): + calls = [] + modules = { + "load": SimpleNamespace(cmd_load=_async_recorder(calls, "load")), + "save": SimpleNamespace(cmd_save=_recorder(calls, "save")), + } + monkeypatch.setattr(SessionsCommand, "loader", _fake_loader(modules)) + + await SessionsCommand.execute(None, None, "load feature-x") + await SessionsCommand.execute(None, None, "save feature-y") + + assert calls == [("load", "feature-x"), ("save", "feature-y")] + + @pytest.mark.asyncio + async def test_unknown_subcommand_reports_error(self): + io = MagicMock() + + await SessionsCommand.execute(io, None, "bogus") + + io.tool_error.assert_called_once() + + +class TestHooksCompletions: + @pytest.mark.parametrize( + "args,expected", + [ + ("", ["list", "load", "remove"]), + ("l", ["list", "load"]), + ("load ", ["alpha", "beta"]), + ("load a", ["alpha"]), + ("remove be", ["beta"]), + ("load alpha be", ["beta"]), + ("bogus ", []), + ], + ) + def test_completions(self, monkeypatch, args, expected): + from cecli.commands.hooks_helpers import common + + monkeypatch.setattr(common, "hook_names", lambda coder: ["alpha", "beta"]) + + assert HooksCommand.get_completions(None, None, args) == expected + + +class TestSessionsCompletions: + @pytest.mark.parametrize( + "args,expected", + [ + ("", ["list", "load", "save"]), + ("s", ["save"]), + ("load ", ["alpha", "beta"]), + ("save al", ["alpha"]), + ("list ", []), + ("bogus ", []), + ], + ) + def test_completions(self, monkeypatch, args, expected): + from cecli.commands.sessions_helpers import common + + monkeypatch.setattr(common, "session_names", lambda coder, io: ["alpha", "beta"]) + + assert SessionsCommand.get_completions(None, None, args) == expected + + +class TestRegistration: + def test_dispatchers_registered_and_legacy_names_removed(self): + assert CommandRegistry.get_command("hooks") is HooksCommand + assert CommandRegistry.get_command("sessions") is SessionsCommand + + for legacy in ( + "load-hook", + "remove-hook", + "list-sessions", + "load-session", + "save-session", + ): + assert CommandRegistry.get_command(legacy) is None + + +class TestFullArgsRegistration: + def test_dispatchers_register_for_full_args_completion(self): + from cecli.commands.core import Commands + + assert "/sessions" in Commands.FULL_ARGS_COMPLETION_COMMANDS + assert "/hooks" in Commands.FULL_ARGS_COMPLETION_COMMANDS + assert "/queue" in Commands.FULL_ARGS_COMPLETION_COMMANDS + assert "/insert-queue" not in Commands.FULL_ARGS_COMPLETION_COMMANDS + assert "/list-queue" not in Commands.FULL_ARGS_COMPLETION_COMMANDS diff --git a/tests/commands/test_queue_commands.py b/tests/commands/test_queue_commands.py index c68523b5407..2d02560243e 100644 --- a/tests/commands/test_queue_commands.py +++ b/tests/commands/test_queue_commands.py @@ -8,11 +8,7 @@ # Import the actual classes to test from cecli.commands.core import Commands -from cecli.commands.insert_queue import InsertQueueCommand -from cecli.commands.list_queue import ListQueueCommand from cecli.commands.queue import QueueCommand -from cecli.commands.remove_queue import RemoveQueueCommand -from cecli.commands.run_queue import RunQueueCommand from cecli.commands.utils.registry import CommandRegistry from cecli.helpers import command_queue from cecli.signals import ReloadProgramSignal, SwitchCoderSignal @@ -372,7 +368,7 @@ class TestListQueueCommand: @pytest.mark.asyncio async def test_itc_07_list_queue_shows_numbered_list(self, mock_io, populated_queue): """ITC-07: /list-queue displays numbered list of queued prompts with timestamps.""" - result = await ListQueueCommand.execute(mock_io, populated_queue, "") + result = await QueueCommand.execute(mock_io, populated_queue, "list") assert result == "Successfully executed list-queue." mock_io.tool_output.assert_called() @@ -383,7 +379,7 @@ async def test_itc_07_list_queue_shows_numbered_list(self, mock_io, populated_qu @pytest.mark.asyncio async def test_itc_08_list_queue_empty_shows_message(self, mock_io, mock_coder): """ITC-08: /list-queue on empty queue shows "Queue is empty" message.""" - result = await ListQueueCommand.execute(mock_io, mock_coder, "") + result = await QueueCommand.execute(mock_io, mock_coder, "list") assert result == "Successfully executed list-queue." mock_io.tool_output.assert_called() @@ -393,7 +389,7 @@ async def test_itc_09_list_queue_handles_coder_commands_none( self, mock_io, mock_coder_no_commands ): """ITC-09: /list-queue handles coder.commands is None with error message.""" - result = await ListQueueCommand.execute(mock_io, mock_coder_no_commands, "") + result = await QueueCommand.execute(mock_io, mock_coder_no_commands, "list") assert "Error" in result or "not available" in result.lower() @@ -402,7 +398,7 @@ async def test_itc_10_list_queue_truncates_long_prompts(self, mock_io): """ITC-10: /list-queue truncates prompts longer than display threshold.""" coder = _make_coder_with_commands(("x" * 120,)) - await ListQueueCommand.execute(mock_io, coder, "") + await QueueCommand.execute(mock_io, coder, "list") calls = [str(call) for call in mock_io.tool_output.call_args_list] output_text = " ".join(calls) @@ -415,7 +411,7 @@ class TestRemoveQueueCommand: @pytest.mark.asyncio async def test_itc_11_remove_by_index(self, mock_io, populated_queue): """ITC-11: /remove-queue removes exact item and confirms removal.""" - result = await RemoveQueueCommand.execute(mock_io, populated_queue, "2") + result = await QueueCommand.execute(mock_io, populated_queue, "remove 2") assert result == "Successfully executed remove-queue." assert len(populated_queue.prompt_queue) == 2 @@ -424,7 +420,7 @@ async def test_itc_11_remove_by_index(self, mock_io, populated_queue): @pytest.mark.asyncio async def test_itc_12_remove_wildcard_clears_all(self, mock_io, populated_queue): """ITC-12: /remove-queue * clears entire queue and confirms count removed.""" - result = await RemoveQueueCommand.execute(mock_io, populated_queue, "*") + result = await QueueCommand.execute(mock_io, populated_queue, "remove *") assert result == "Successfully executed remove-queue." assert len(populated_queue.prompt_queue) == 0 @@ -433,7 +429,7 @@ async def test_itc_12_remove_wildcard_clears_all(self, mock_io, populated_queue) @pytest.mark.asyncio async def test_itc_13_remove_interactive_mode(self, mock_io, populated_queue): """ITC-13: /remove-queue with no args enters interactive selection.""" - result = await RemoveQueueCommand.execute(mock_io, populated_queue, "") + result = await QueueCommand.execute(mock_io, populated_queue, "remove") # Interactive mode shows queue list and prompt, returns success status assert result == "Successfully executed remove-queue." @@ -445,41 +441,41 @@ async def test_itc_13_remove_interactive_mode(self, mock_io, populated_queue): @pytest.mark.asyncio async def test_itc_14_remove_invalid_index_non_integer(self, mock_io, populated_queue): """ITC-14: /remove-queue with non-integer index shows invalid index error.""" - result = await RemoveQueueCommand.execute(mock_io, populated_queue, "abc") + result = await QueueCommand.execute(mock_io, populated_queue, "remove abc") assert "Error" in result or "Invalid index" in result @pytest.mark.asyncio async def test_itc_15_remove_out_of_bounds_index(self, mock_io, populated_queue): """ITC-15: /remove-queue with out-of-bounds index shows error.""" - result = await RemoveQueueCommand.execute(mock_io, populated_queue, "99") + result = await QueueCommand.execute(mock_io, populated_queue, "remove 99") assert "Error" in result or "out of range" in result.lower() @pytest.mark.asyncio async def test_itc_16_remove_negative_index(self, mock_io, populated_queue): """ITC-16: /remove-queue with negative index shows error.""" - result = await RemoveQueueCommand.execute(mock_io, populated_queue, "-1") + result = await QueueCommand.execute(mock_io, populated_queue, "remove -1") assert "Error" in result or "Invalid index" in result @pytest.mark.asyncio async def test_itc_17_remove_empty_queue(self, mock_io, mock_coder): """ITC-17: /remove-queue on empty queue shows error.""" - result = await RemoveQueueCommand.execute(mock_io, mock_coder, "1") + result = await QueueCommand.execute(mock_io, mock_coder, "remove 1") assert "Error" in result or "empty" in result.lower() @pytest.mark.asyncio async def test_itc_18_remove_handles_coder_commands_none(self, mock_io, mock_coder_no_commands): """ITC-18: /remove-queue handles coder.commands is None.""" - result = await RemoveQueueCommand.execute(mock_io, mock_coder_no_commands, "1") + result = await QueueCommand.execute(mock_io, mock_coder_no_commands, "remove 1") assert "Error" in result or "not available" in result.lower() def test_itc_19_get_completions_returns_indices_and_wildcard(self, populated_queue): """ITC-19: RemoveQueueCommand.get_completions() returns valid index completions and '*'.""" - completions = RemoveQueueCommand.get_completions(None, populated_queue, "") + completions = QueueCommand.get_completions(None, populated_queue, "remove ") assert "1" in completions assert "2" in completions @@ -487,11 +483,11 @@ def test_itc_19_get_completions_returns_indices_and_wildcard(self, populated_que assert "*" in completions def test_itc_20_commands_registered_in_registry(self): - """ITC-20: All four queue commands are registered and discoverable via registry lookup.""" - assert CommandRegistry.get_command("queue") is not None - assert CommandRegistry.get_command("list-queue") is not None - assert CommandRegistry.get_command("remove-queue") is not None - assert CommandRegistry.get_command("run-queue") is not None + """ITC-20: The queue dispatcher is registered and legacy names are gone.""" + assert CommandRegistry.get_command("queue") is QueueCommand + + for legacy in ("list-queue", "insert-queue", "remove-queue", "run-queue"): + assert CommandRegistry.get_command(legacy) is None class TestRunQueueCommand: @@ -510,7 +506,7 @@ async def fake_drain(): coder.process_prompt_queue = fake_drain - result = await RunQueueCommand.execute(mock_io, coder, "") + result = await QueueCommand.execute(mock_io, coder, "run") assert result == "Successfully executed run-queue." assert processed == ["first", "second", "third"] @@ -526,7 +522,7 @@ async def failing_drain(): coder.process_prompt_queue = failing_drain - result = await RunQueueCommand.execute(mock_io, coder, "") + result = await QueueCommand.execute(mock_io, coder, "run") assert "Error" in result assert "drain exploded" in result @@ -535,7 +531,7 @@ async def failing_drain(): @pytest.mark.asyncio async def test_itc_23_run_queue_empty_queue_informs_user(self, mock_io, mock_coder): """ITC-23: /run-queue on an empty queue informs the user without error.""" - result = await RunQueueCommand.execute(mock_io, mock_coder, "") + result = await QueueCommand.execute(mock_io, mock_coder, "run") assert result == "Successfully executed run-queue." calls = [str(call) for call in mock_io.tool_output.call_args_list] @@ -546,7 +542,7 @@ async def test_itc_24_run_queue_handles_coder_commands_none( self, mock_io, mock_coder_no_commands ): """ITC-24: /run-queue handles coder.commands is None with error message.""" - result = await RunQueueCommand.execute(mock_io, mock_coder_no_commands, "") + result = await QueueCommand.execute(mock_io, mock_coder_no_commands, "run") assert "Error" in result or "not available" in result.lower() @@ -622,7 +618,7 @@ async def fake_drain(): coder.process_prompt_queue = fake_drain - result = await InsertQueueCommand.execute(mock_io, coder, "1 idle insert") + result = await QueueCommand.execute(mock_io, coder, "insert 1 idle insert") assert result == "Successfully executed insert-queue." assert drain_calls == [] @@ -641,7 +637,7 @@ async def fake_drain(): coder.process_prompt_queue = fake_drain - result = await InsertQueueCommand.execute(mock_io, coder, "1 busy insert") + result = await QueueCommand.execute(mock_io, coder, "insert 1 busy insert") assert result == "Successfully executed insert-queue." assert drain_calls == [] @@ -847,7 +843,10 @@ async def signal_generate(text, preproc=True): with ( patch("cecli.coders.Coder.create", AsyncMock(return_value=new_coder)) as create, - patch("cecli.helpers.agents.service.AgentService.get_instance", return_value=service), + patch( + "cecli.helpers.agents.service.AgentService.get_instance", + return_value=service, + ), ): await Coder.process_prompt_queue(coder) @@ -909,8 +908,8 @@ def test_insert_queue_argument_completion_preserves_inner_command(self, mock_io) app = self._app(coder) assert ( - app._get_completed_text("/insert-queue 1 /save-session ", "auto-save") - == "/insert-queue 1 /save-session auto-save" + app._get_completed_text("/queue insert1 /sessions save ", "auto-save") + == "/queue insert1 /sessions save auto-save" ) def test_non_nested_argument_completion_keeps_existing_behavior(self, mock_io): @@ -927,13 +926,13 @@ class TestNestedCompletions: """Unit tests for nested slash-command completions in /queue and /insert-queue.""" def test_ntc_01_queue_prefix_matches_registry_commands(self, mock_io): - """NTC-01: /queue /sa offers inner commands whose names start with "sa".""" + """NTC-01: /queue /sess offers inner commands whose names start with "sess".""" coder = _make_coder_with_commands() - result = QueueCommand.get_completions(mock_io, coder, "/sa") + result = QueueCommand.get_completions(mock_io, coder, "/sess") - assert "/save-session" in result - assert all(name.startswith("/sa") for name in result) + assert "/sessions" in result + assert all(name.startswith("/sess") for name in result) assert result == sorted(result) def test_ntc_02_queue_full_inner_command_delegates_to_original(self, mock_io): @@ -949,23 +948,22 @@ def test_ntc_02_queue_full_inner_command_delegates_to_original(self, mock_io): coder.commands.get_completions.assert_called_once_with("/add", "", coder=coder) def test_ntc_03_queue_save_session_delegates_session_names(self, mock_io): - """NTC-03: /queue /save-session delegates to SaveSessionCommand so - existing session names are offered exactly like /save-session .""" + """NTC-03: /queue add /sessions save delegates to SessionsCommand.""" coder = _make_coder_with_commands() session_names = ["auto-save", "feature-x"] coder.commands.get_completions = MagicMock(return_value=session_names) - result = QueueCommand.get_completions(mock_io, coder, "/save-session ") + result = QueueCommand.get_completions(mock_io, coder, "add /sessions save ") assert result == session_names - coder.commands.get_completions.assert_called_once_with("/save-session", "", coder=coder) + coder.commands.get_completions.assert_called_once_with("/sessions", "save ", coder=coder) def test_ntc_04_queue_plain_text_returns_empty(self, mock_io): """NTC-04: non-command queued text (and empty args) have no completions.""" coder = _make_coder_with_commands() assert QueueCommand.get_completions(mock_io, coder, "plain text") == [] - assert QueueCommand.get_completions(mock_io, coder, "") == [] + assert QueueCommand.get_completions(mock_io, coder, "") == list(QueueCommand.SUBCOMMANDS) def test_ntc_05_queue_unknown_inner_command_returns_empty(self, mock_io): """NTC-05: delegation errors (e.g. unknown inner command) degrade to [].""" @@ -979,22 +977,22 @@ def boom(cmd, args="", coder=None): assert QueueCommand.get_completions(mock_io, coder, "/no-such-cmd arg ") == [] def test_ntc_06_insert_queue_delegates_after_index(self, mock_io): - """NTC-06: /insert-queue 1 /save-session delegates after the index.""" + """NTC-06: /queue insert 1 /sessions delegates after the index.""" coder = _make_coder_with_commands() session_names = ["auto-save"] coder.commands.get_completions = MagicMock(return_value=session_names) - result = InsertQueueCommand.get_completions(mock_io, coder, "1 /save-session ") + result = QueueCommand.get_completions(mock_io, coder, "insert 1 /sessions ") assert result == session_names - coder.commands.get_completions.assert_called_once_with("/save-session", "", coder=coder) + coder.commands.get_completions.assert_called_once_with("/sessions", "", coder=coder) def test_ntc_07_insert_queue_index_only_suggests_positions(self, mock_io): - """NTC-07: while typing the index, /insert-queue offers clamped positions.""" + """NTC-07: while typing the index, /queue insert offers valid positions.""" coder = _make_coder_with_commands() command_queue.enqueue_prompt(coder, "only item") - result = InsertQueueCommand.get_completions(mock_io, coder, "1") + result = QueueCommand.get_completions(mock_io, coder, "insert 1") # Queue holds 1 item, so positions 1..2 (append at end) are valid. assert result == ["1", "2"] @@ -1039,12 +1037,7 @@ async def test_etc_06_management_commands_dont_trigger_processing( self, mock_io, clean_commands ): """ETC-06: Management commands do not trigger auto-processing of queued items.""" - assert clean_commands._MANAGEMENT_COMMANDS == { - "queue", - "list-queue", - "remove-queue", - "run-queue", - } + assert clean_commands._MANAGEMENT_COMMANDS == {"queue"} @pytest.mark.asyncio async def test_etc_05_prevent_infinite_loop(self, populated_queue): @@ -1114,17 +1107,10 @@ def test_rtc_03_execute_preserves_existing_flow(self, mock_io, mock_coder): assert callable(mock_coder.commands.execute) def test_rtc_04_init_py_imports_all_commands(self): - """RTC-04: cecli/commands/__init__.py imports all commands without conflicts.""" - from cecli.commands import ( - CommandRegistry, - ListQueueCommand, - QueueCommand, - RemoveQueueCommand, - ) + """RTC-04: cecli/commands/__init__.py imports the queue dispatcher.""" + from cecli.commands import CommandRegistry, QueueCommand assert CommandRegistry.get_command("queue") is QueueCommand - assert CommandRegistry.get_command("list-queue") is ListQueueCommand - assert CommandRegistry.get_command("remove-queue") is RemoveQueueCommand def test_rtc_05_thread_safety_under_concurrent_access(self, clean_commands): """RTC-05: The queue lock is a real threading.Lock created on first use.""" @@ -1177,38 +1163,29 @@ class TestCommandRegistration: """Tests for command registration in CommandRegistry.""" def test_all_queue_commands_registered(self): - """Verify all queue commands are properly registered.""" + """Verify the queue dispatcher is registered under its family name.""" commands = CommandRegistry.list_commands() assert "queue" in commands - assert "list-queue" in commands - assert "remove-queue" in commands - assert "run-queue" in commands + assert "list-queue" not in commands + assert "insert-queue" not in commands + assert "remove-queue" not in commands + assert "run-queue" not in commands def test_command_classes_have_required_attributes(self): - """Verify command classes have NORM_NAME and DESCRIPTION.""" + """Verify the dispatcher exposes its subcommands and helper modules.""" assert QueueCommand.NORM_NAME == "queue" assert QueueCommand.DESCRIPTION is not None + assert QueueCommand.SUBCOMMANDS == ("add", "insert", "list", "remove", "run") - assert ListQueueCommand.NORM_NAME == "list-queue" - assert ListQueueCommand.DESCRIPTION is not None - - assert RemoveQueueCommand.NORM_NAME == "remove-queue" - assert RemoveQueueCommand.DESCRIPTION is not None - - assert RunQueueCommand.NORM_NAME == "run-queue" - assert RunQueueCommand.DESCRIPTION is not None + for sub in QueueCommand.SUBCOMMANDS: + assert sub in QueueCommand.HELPER_MODULES def test_command_classes_have_execute_method(self): - """Verify command classes have async execute method.""" + """Verify the dispatcher has an async execute method.""" assert hasattr(QueueCommand, "execute") - assert hasattr(ListQueueCommand, "execute") - assert hasattr(RemoveQueueCommand, "execute") - assert callable(QueueCommand.execute) - assert callable(ListQueueCommand.execute) - assert callable(RemoveQueueCommand.execute) - assert callable(RunQueueCommand.execute) + assert callable(QueueCommand.get_completions) # ============================================================================ @@ -1306,17 +1283,17 @@ def spy(cls, io, coder_, args): assert seen == ["s"] def test_tcp_03_queue_inner_command_names(self): - """TCP-03: /queue /sa surfaces matching inner command names.""" + """TCP-03: /queue /sess surfaces matching inner command names.""" coder = _make_coder_with_commands() - result = coder.commands.get_completions("/queue", args="/sa") + result = coder.commands.get_completions("/queue", args="/sess") - assert "/save-session" in result - assert all(name.startswith("/sa") for name in result) + assert "/sessions" in result + assert all(name.startswith("/sess") for name in result) def test_tcp_04_queue_save_session_delegates_session_names(self, monkeypatch): - """TCP-04: /queue /save-session pre reaches SaveSessionCommand with "pre".""" - from cecli.commands.save_session import SaveSessionCommand + """TCP-04: /queue add /sessions save pre reaches SessionsCommand.""" + from cecli.commands.sessions import SessionsCommand coder = _make_coder_with_commands() seen = [] @@ -1325,12 +1302,12 @@ def spy(cls, io, coder_, args): seen.append(args) return ["presentation"] - monkeypatch.setattr(SaveSessionCommand, "get_completions", classmethod(spy)) + monkeypatch.setattr(SessionsCommand, "get_completions", classmethod(spy)) - result = coder.commands.get_completions("/queue", args="/save-session pre") + result = coder.commands.get_completions("/queue", args="add /sessions save pre") assert result == ["presentation"] - assert seen == ["pre"] + assert seen == ["save pre"] def test_tcp_05_queue_plain_text_returns_empty(self): """TCP-05: /queue plain text has no completions.""" @@ -1339,7 +1316,7 @@ def test_tcp_05_queue_plain_text_returns_empty(self): assert coder.commands.get_completions("/queue", args="plain text") == [] def test_tcp_06_insert_queue_delegates_after_index(self, monkeypatch): - """TCP-06: /insert-queue 1 /add delegates to AddCommand after index.""" + """TCP-06: /queue insert1 /add delegates to AddCommand after index.""" from cecli.commands.add import AddCommand coder = _make_coder_with_commands() @@ -1351,7 +1328,7 @@ def spy(cls, io, coder_, args): monkeypatch.setattr(AddCommand, "get_completions", classmethod(spy)) - result = coder.commands.get_completions("/insert-queue", args="1 /add ") + result = coder.commands.get_completions("/queue", args="insert 1 /add ") assert result == ["file_a.py"] assert seen == [""] From 70d006b7e79682c0918e644e1795cc6208ff0b6c Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 00:01:28 -0400 Subject: [PATCH 09/13] Add sub command completions --- cecli/commands/hooks.py | 5 ++++ cecli/commands/mcp.py | 5 ++++ cecli/commands/queue.py | 7 +++++ cecli/commands/sessions.py | 5 ++++ cecli/commands/skills.py | 8 +++++ cecli/commands/utils/base_command.py | 1 + cecli/commands/utils/registry.py | 14 +++++++-- cecli/commands/workflows.py | 10 +++++++ cecli/tui/app.py | 39 ++++++++++-------------- tests/commands/test_subcommand_usage.py | 40 +++++++++++++++++++++++++ 10 files changed, 108 insertions(+), 26 deletions(-) create mode 100644 tests/commands/test_subcommand_usage.py diff --git a/cecli/commands/hooks.py b/cecli/commands/hooks.py index a2a75117be5..5f6fee15c36 100644 --- a/cecli/commands/hooks.py +++ b/cecli/commands/hooks.py @@ -21,6 +21,11 @@ class HooksCommand(BaseCommand): "load": "cecli.commands.hooks_helpers.load", "remove": "cecli.commands.hooks_helpers.remove", } + SUBCOMMAND_USAGE = { + "list": "/hooks list [--type T] [--enabled-only|--disabled-only]", + "load": "/hooks load ...", + "remove": "/hooks remove ...", + } @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/commands/mcp.py b/cecli/commands/mcp.py index a3b2ef7270b..c2fea11a689 100644 --- a/cecli/commands/mcp.py +++ b/cecli/commands/mcp.py @@ -18,6 +18,11 @@ class McpCommand(BaseCommand): "load": "cecli.commands.mcp_helpers.load", "remove": "cecli.commands.mcp_helpers.remove", } + SUBCOMMAND_USAGE = { + "list": "/mcp list", + "load": "/mcp load ...", + "remove": "/mcp remove ...", + } @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/commands/queue.py b/cecli/commands/queue.py index 4a288e8157b..0fdb960c404 100644 --- a/cecli/commands/queue.py +++ b/cecli/commands/queue.py @@ -22,6 +22,13 @@ class QueueCommand(BaseCommand): "remove": "cecli.commands.queue_helpers.remove", "run": "cecli.commands.queue_helpers.run", } + SUBCOMMAND_USAGE = { + "add": "/queue add ", + "insert": "/queue insert ", + "list": "/queue list", + "remove": "/queue remove |*", + "run": "/queue run", + } @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/commands/sessions.py b/cecli/commands/sessions.py index 889ad5206e0..eb735510267 100644 --- a/cecli/commands/sessions.py +++ b/cecli/commands/sessions.py @@ -20,6 +20,11 @@ class SessionsCommand(BaseCommand): "load": "cecli.commands.sessions_helpers.load", "save": "cecli.commands.sessions_helpers.save", } + SUBCOMMAND_USAGE = { + "list": "/sessions list", + "load": "/sessions load ", + "save": "/sessions save ", + } @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/commands/skills.py b/cecli/commands/skills.py index 30dc5456b52..eccd21f183a 100644 --- a/cecli/commands/skills.py +++ b/cecli/commands/skills.py @@ -22,6 +22,14 @@ class SkillsCommand(BaseCommand): "remove": "cecli.commands.skills_helpers.remove", "import": "cecli.commands.skills_helpers.import_skill", } + SUBCOMMAND_USAGE = { + "list": "/skills list", + "load": "/skills load ...", + "include": "/skills include ...", + "exclude": "/skills exclude ...", + "remove": "/skills remove ...", + "import": "/skills import [--global] ", + } @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index 8716b210cb2..bd6a898568c 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -38,6 +38,7 @@ class BaseCommand(ABC, metaclass=CommandMeta): DESCRIPTION = None # Command description for help SCHEMA = None # Optional schema for parameter validation HELPER_MODULES = {} # subcommand -> helper module path (optional) + SUBCOMMAND_USAGE = {} # subcommand -> usage hint shown in TUI key hints show_completion_notification = False @classmethod diff --git a/cecli/commands/utils/registry.py b/cecli/commands/utils/registry.py index 7df118076e3..834f9fb8c43 100644 --- a/cecli/commands/utils/registry.py +++ b/cecli/commands/utils/registry.py @@ -30,19 +30,27 @@ async def execute(cls, name, io, coder, args, **kwargs): return await command_class.process_command(io, coder, args, **kwargs) @classmethod - def get_command_description(cls, name: str) -> str: + def get_command_description(cls, name: str, subcommand: str = "") -> str: """ - Get description for a specific command. + Get description for a specific command or one of its subcommands. Args: name: Command name + subcommand: Optional subcommand name Returns: - Command description string or empty string if command not found + Usage hint for the subcommand when one is registered, otherwise the + command description, or an empty string if the command is not found """ command_class = cls.get_command(name) if not command_class: return "" + + if subcommand: + usage = getattr(command_class, "SUBCOMMAND_USAGE", {}) + if subcommand in usage: + return usage[subcommand] + return command_class.DESCRIPTION or "" @classmethod diff --git a/cecli/commands/workflows.py b/cecli/commands/workflows.py index c1dd548b45d..a72525e52e7 100644 --- a/cecli/commands/workflows.py +++ b/cecli/commands/workflows.py @@ -24,6 +24,16 @@ class WorkflowCommand(BaseCommand): "toggle": "cecli.commands.workflow_helpers.toggle", "reload": "cecli.commands.workflow_helpers.reload", } + SUBCOMMAND_USAGE = { + "list": "/workflows list", + "run": "/workflows run ", + "show": "/workflows show ", + "status": "/workflows status [name]", + "last": "/workflows last ", + "enable": "/workflows enable ", + "disable": "/workflows disable ", + "reload": "/workflows reload", + } @classmethod async def execute(cls, io, coder, args, **kwargs): diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 551ce76eeae..1169df3fc84 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -456,35 +456,27 @@ def set_voice_hint(self, text: str): except Exception: pass - def _update_key_hints_for_commands(self, text: str, is_completion: bool = False): + def _update_key_hints_for_commands(self, text: str): """ - Update key hints left area with command description. - - Handles both regular input text and completion suggestions. + Update key hints left area with command or subcommand description. Args: - text: The text to analyze (input text or completion suggestion) - is_completion: Whether this is a completion suggestion (default: False) + text: The text to analyze (input text or completion preview) """ # Check if text starts with slash if text.startswith("/"): - # Extract command name - # For completions, we just need to remove the leading slash - # For regular input, we need to extract the first word after slash - if is_completion: - # Completion suggestion like "/help" - just remove leading slash - cmd_name = text[1:].strip() - else: - # Regular input like "/help arg1 arg2" - extract first word - parts = text[1:].strip().split() - cmd_name = parts[0] if parts else "" + # Extract the command name and optional subcommand. Completion + # previews are inserted text, so the same parsing works for both. + parts = text[1:].strip().split() + cmd_name = parts[0] if parts else "" + subcommand = parts[1].lower() if len(parts) > 1 else "" # Get command description if we have a command name if cmd_name: try: from cecli.commands.utils.registry import CommandRegistry - description = CommandRegistry.get_command_description(cmd_name) + description = CommandRegistry.get_command_description(cmd_name, subcommand) if description: self.update_key_hints_left(f"{description}") return @@ -814,7 +806,7 @@ def on_resize(self) -> None: def on_input_area_text_changed(self, message: InputArea.TextChanged): """Handle text changes in input area.""" - self._update_key_hints_for_commands(message.text, is_completion=False) + self._update_key_hints_for_commands(message.text) def on_input_area_submit(self, message: InputArea.Submit): """Handle input submission.""" @@ -2141,7 +2133,8 @@ def on_input_area_completion_requested(self, message: InputArea.CompletionReques # Update key hints with description for first suggestion if suggestions: first_suggestion = suggestions[0] - self._update_key_hints_for_commands(first_suggestion, is_completion=True) + preview = self._get_completed_text(text, first_suggestion) + self._update_key_hints_for_commands(preview) else: # No suggestions - dismiss if active input_area.completion_active = False @@ -2161,7 +2154,7 @@ def on_input_area_completion_cycle(self, message: InputArea.CompletionCycle): new_text = self._get_completed_text(base_text, selected) input_area.set_completion_preview(new_text) # Update key hints with command description for selected completion - self._update_key_hints_for_commands(selected, is_completion=True) + self._update_key_hints_for_commands(new_text) except Exception: pass @@ -2178,7 +2171,7 @@ def on_input_area_completion_cycle_previous(self, message: InputArea.CompletionC new_text = self._get_completed_text(base_text, selected) input_area.set_completion_preview(new_text) # Update key hints with command description for selected completion - self._update_key_hints_for_commands(selected, is_completion=True) + self._update_key_hints_for_commands(new_text) except Exception: pass @@ -2191,7 +2184,7 @@ def on_input_area_completion_accept(self, message: InputArea.CompletionAccept): pass # Update key hints based on accepted completion input_area = self.query_one("#input", InputArea) - self._update_key_hints_for_commands(input_area.text, is_completion=False) + self._update_key_hints_for_commands(input_area.text) def on_input_area_completion_dismiss(self, message: InputArea.CompletionDismiss): """Handle Escape to dismiss completions.""" @@ -2203,7 +2196,7 @@ def on_input_area_completion_dismiss(self, message: InputArea.CompletionDismiss) except Exception: pass # Update key hints back to normal based on current input - self._update_key_hints_for_commands(input_area.text, is_completion=False) + self._update_key_hints_for_commands(input_area.text) def on_completion_bar_selected(self, message: CompletionBar.Selected): """Handle completion selection.""" diff --git a/tests/commands/test_subcommand_usage.py b/tests/commands/test_subcommand_usage.py new file mode 100644 index 00000000000..08a3962ad4d --- /dev/null +++ b/tests/commands/test_subcommand_usage.py @@ -0,0 +1,40 @@ +"""Tests for subcommand usage hints shown by the TUI.""" + +import pytest + +from cecli.commands import ( + CommandRegistry, + HooksCommand, + McpCommand, + QueueCommand, + SessionsCommand, + SkillsCommand, + WorkflowCommand, +) + +DISPATCHERS = ( + HooksCommand, + McpCommand, + QueueCommand, + SessionsCommand, + SkillsCommand, + WorkflowCommand, +) + + +@pytest.mark.parametrize("command_class", DISPATCHERS) +def test_every_subcommand_has_a_usage_hint(command_class): + assert set(command_class.SUBCOMMANDS) == set(command_class.SUBCOMMAND_USAGE) + + +@pytest.mark.parametrize("command_class", DISPATCHERS) +def test_get_command_description_returns_subcommand_usage(command_class): + for subcommand in command_class.SUBCOMMANDS: + description = CommandRegistry.get_command_description(command_class.NORM_NAME, subcommand) + + assert description == command_class.SUBCOMMAND_USAGE[subcommand] + + +def test_unknown_subcommand_falls_back_to_command_description(): + assert CommandRegistry.get_command_description("mcp", "bogus") == McpCommand.DESCRIPTION + assert CommandRegistry.get_command_description("mcp") == McpCommand.DESCRIPTION From 2fe75cd502bec71726a13d6bb9169804c85a14ff Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 00:18:31 -0400 Subject: [PATCH 10/13] Add command aliases for verb first commands for easy look up --- cecli/commands/core.py | 29 +++++++++--- cecli/commands/help.py | 2 +- cecli/commands/hooks.py | 5 +++ cecli/commands/mcp.py | 5 +++ cecli/commands/queue.py | 7 +++ cecli/commands/sessions.py | 5 +++ cecli/commands/skills.py | 8 ++++ cecli/commands/utils/base_command.py | 1 + cecli/commands/utils/registry.py | 44 ++++++++++++++++--- cecli/commands/workflows.py | 10 +++++ cecli/helpers/command_queue.py | 4 +- cecli/io.py | 6 ++- cecli/tui/app.py | 4 +- .../commands/test_hooks_sessions_commands.py | 21 +++++---- tests/commands/test_mcp_skills_completions.py | 30 +++++++++++-- tests/commands/test_queue_commands.py | 20 +++++---- tests/commands/test_subcommand_usage.py | 44 +++++++++++++++++++ 17 files changed, 207 insertions(+), 38 deletions(-) diff --git a/cecli/commands/core.py b/cecli/commands/core.py index 29f55332a4f..b7aff41bc7f 100644 --- a/cecli/commands/core.py +++ b/cecli/commands/core.py @@ -176,6 +176,12 @@ def get_raw_completions(self, cmd): def get_completions(self, cmd, args="", coder=None): assert cmd.startswith("/") cmd = cmd[1:] + + alias = CommandRegistry.resolve_alias(cmd) + if alias: + cmd, subcommand = alias + args = f"{subcommand} {args}" if args else f"{subcommand} " + command_class = CommandRegistry.get_command(cmd) if command_class: return command_class.get_completions(self.io, coder or self.coder, args) @@ -187,6 +193,14 @@ def has_subcommands(self, cmd): command_class = CommandRegistry.get_command(cmd) return bool(getattr(command_class, "SUBCOMMANDS", ())) + def uses_full_args_completion(self, cmd): + """Return True when *cmd* needs its whole argument string for completion.""" + if cmd in self.FULL_ARGS_COMPLETION_COMMANDS: + return True + + name = cmd[1:] if cmd.startswith("/") else cmd + return CommandRegistry.resolve_alias(name) is not None + def at_subcommand_boundary(self, text): """Return True when *text* ends after a command or first subcommand token.""" if not text.endswith(" "): @@ -201,8 +215,8 @@ def at_subcommand_boundary(self, text): return len(words) <= 2 - def get_commands(self): - registry_commands = CommandRegistry.list_commands() + def get_commands(self, include_aliases=True): + registry_commands = CommandRegistry.list_commands(include_aliases=include_aliases) commands = [f"/{cmd}" for cmd in registry_commands] return sorted(commands) @@ -216,8 +230,11 @@ async def execute(self, cmd_name, args, coder=None, **kwargs): active_coder.io.tool_output(f"Error: Command {cmd_name} not found.") return + alias = CommandRegistry.resolve_alias(cmd_name) + management_name = alias[0] if alias else cmd_name + self.last_command_show_notification = command_class.show_completion_notification - if cmd_name not in self._MANAGEMENT_COMMANDS: + if management_name not in self._MANAGEMENT_COMMANDS: self.cmd_running_event.clear() try: @@ -250,13 +267,13 @@ async def execute(self, cmd_name, args, coder=None, **kwargs): if self.coder.tui and self.coder.tui(): self.coder.tui().refresh() - def matching_commands(self, inp): + def matching_commands(self, inp, include_aliases=False): words = inp.strip().split() if not words: return first_word = words[0] rest_inp = inp[len(words[0]) :].strip() - all_commands = self.get_commands() + all_commands = self.get_commands(include_aliases=include_aliases) matching_commands = [cmd for cmd in all_commands if cmd.startswith(first_word)] return matching_commands, first_word, rest_inp @@ -293,7 +310,7 @@ async def run(self, inp, coder=None, **kwargs): def get_help_md(self): """Show help about all commands in markdown""" res = "\n|Command|Description|\n|:------|:----------|\n" - commands = sorted(self.get_commands()) + commands = sorted(self.get_commands(include_aliases=False)) for cmd in commands: cmd_name = cmd[1:] command_class = CommandRegistry.get_command(cmd_name) diff --git a/cecli/commands/help.py b/cecli/commands/help.py index 7ad6a9a4df5..13351850c41 100644 --- a/cecli/commands/help.py +++ b/cecli/commands/help.py @@ -113,7 +113,7 @@ async def _basic_help(cls, io, coder): from cecli.commands import Commands commands_instance = Commands(io, coder) - all_commands = commands_instance.get_commands() + all_commands = commands_instance.get_commands(include_aliases=False) pad = max(len(cmd) for cmd in all_commands) pad_format = "{cmd:" + str(pad) + "}" diff --git a/cecli/commands/hooks.py b/cecli/commands/hooks.py index 5f6fee15c36..d74abd611c9 100644 --- a/cecli/commands/hooks.py +++ b/cecli/commands/hooks.py @@ -21,6 +21,11 @@ class HooksCommand(BaseCommand): "load": "cecli.commands.hooks_helpers.load", "remove": "cecli.commands.hooks_helpers.remove", } + ALIASES = { + "list-hooks": "list", + "load-hook": "load", + "remove-hook": "remove", + } SUBCOMMAND_USAGE = { "list": "/hooks list [--type T] [--enabled-only|--disabled-only]", "load": "/hooks load ...", diff --git a/cecli/commands/mcp.py b/cecli/commands/mcp.py index c2fea11a689..cc9318bd0c8 100644 --- a/cecli/commands/mcp.py +++ b/cecli/commands/mcp.py @@ -18,6 +18,11 @@ class McpCommand(BaseCommand): "load": "cecli.commands.mcp_helpers.load", "remove": "cecli.commands.mcp_helpers.remove", } + ALIASES = { + "list-mcp": "list", + "load-mcp": "load", + "remove-mcp": "remove", + } SUBCOMMAND_USAGE = { "list": "/mcp list", "load": "/mcp load ...", diff --git a/cecli/commands/queue.py b/cecli/commands/queue.py index 0fdb960c404..81d9ff1b111 100644 --- a/cecli/commands/queue.py +++ b/cecli/commands/queue.py @@ -22,6 +22,13 @@ class QueueCommand(BaseCommand): "remove": "cecli.commands.queue_helpers.remove", "run": "cecli.commands.queue_helpers.run", } + ALIASES = { + "add-queue": "add", + "insert-queue": "insert", + "list-queue": "list", + "remove-queue": "remove", + "run-queue": "run", + } SUBCOMMAND_USAGE = { "add": "/queue add ", "insert": "/queue insert ", diff --git a/cecli/commands/sessions.py b/cecli/commands/sessions.py index eb735510267..6cc911d6010 100644 --- a/cecli/commands/sessions.py +++ b/cecli/commands/sessions.py @@ -20,6 +20,11 @@ class SessionsCommand(BaseCommand): "load": "cecli.commands.sessions_helpers.load", "save": "cecli.commands.sessions_helpers.save", } + ALIASES = { + "list-sessions": "list", + "load-session": "load", + "save-session": "save", + } SUBCOMMAND_USAGE = { "list": "/sessions list", "load": "/sessions load ", diff --git a/cecli/commands/skills.py b/cecli/commands/skills.py index eccd21f183a..94308314013 100644 --- a/cecli/commands/skills.py +++ b/cecli/commands/skills.py @@ -22,6 +22,14 @@ class SkillsCommand(BaseCommand): "remove": "cecli.commands.skills_helpers.remove", "import": "cecli.commands.skills_helpers.import_skill", } + ALIASES = { + "list-skills": "list", + "load-skill": "load", + "include-skill": "include", + "exclude-skill": "exclude", + "remove-skill": "remove", + "import-skill": "import", + } SUBCOMMAND_USAGE = { "list": "/skills list", "load": "/skills load ...", diff --git a/cecli/commands/utils/base_command.py b/cecli/commands/utils/base_command.py index bd6a898568c..0c658958388 100644 --- a/cecli/commands/utils/base_command.py +++ b/cecli/commands/utils/base_command.py @@ -39,6 +39,7 @@ class BaseCommand(ABC, metaclass=CommandMeta): SCHEMA = None # Optional schema for parameter validation HELPER_MODULES = {} # subcommand -> helper module path (optional) SUBCOMMAND_USAGE = {} # subcommand -> usage hint shown in TUI key hints + ALIASES = {} # alias name -> subcommand it routes to show_completion_notification = False @classmethod diff --git a/cecli/commands/utils/registry.py b/cecli/commands/utils/registry.py index 834f9fb8c43..1a83554903e 100644 --- a/cecli/commands/utils/registry.py +++ b/cecli/commands/utils/registry.py @@ -2,26 +2,54 @@ class CommandRegistry: """Registry for command discovery and execution.""" _commands = {} # name -> BaseCommand class + _aliases = {} # alias name -> (command name, subcommand) @classmethod def register(cls, command_class): - """Register a command class.""" + """Register a command class and its aliases.""" name = command_class.NORM_NAME cls._commands[name] = command_class + subcommands = getattr(command_class, "SUBCOMMANDS", ()) + for alias, subcommand in getattr(command_class, "ALIASES", {}).items(): + if subcommand not in subcommands: + raise ValueError( + f"Alias '{alias}' for '{name}' routes to unknown subcommand '{subcommand}'" + ) + + cls._aliases[alias] = (name, subcommand) + + @classmethod + def resolve_alias(cls, name): + """Return ``(command name, subcommand)`` for an alias, or None.""" + return cls._aliases.get(name) + @classmethod def get_command(cls, name): - """Get command class by name.""" + """Get command class by name, resolving aliases to their target.""" + alias = cls._aliases.get(name) + if alias: + name = alias[0] + return cls._commands.get(name) @classmethod - def list_commands(cls): - """List all registered commands.""" - return list(cls._commands.keys()) + def list_commands(cls, include_aliases=False): + """List registered command names, optionally including aliases.""" + names = list(cls._commands.keys()) + if include_aliases: + names.extend(cls._aliases.keys()) + + return names @classmethod async def execute(cls, name, io, coder, args, **kwargs): - """Execute a command by name.""" + """Execute a command by name, expanding aliases to their subcommand.""" + alias = cls.resolve_alias(name) + if alias: + name, subcommand = alias + args = f"{subcommand} {args}".strip() + command_class = cls.get_command(name) if not command_class: io.tool_error(f"Command not found: {name}") @@ -42,6 +70,10 @@ def get_command_description(cls, name: str, subcommand: str = "") -> str: Usage hint for the subcommand when one is registered, otherwise the command description, or an empty string if the command is not found """ + alias = cls.resolve_alias(name) + if alias: + name, subcommand = alias + command_class = cls.get_command(name) if not command_class: return "" diff --git a/cecli/commands/workflows.py b/cecli/commands/workflows.py index a72525e52e7..cc5aec07706 100644 --- a/cecli/commands/workflows.py +++ b/cecli/commands/workflows.py @@ -24,6 +24,16 @@ class WorkflowCommand(BaseCommand): "toggle": "cecli.commands.workflow_helpers.toggle", "reload": "cecli.commands.workflow_helpers.reload", } + ALIASES = { + "list-workflows": "list", + "run-workflow": "run", + "show-workflow": "show", + "status-workflow": "status", + "last-workflow": "last", + "enable-workflow": "enable", + "disable-workflow": "disable", + "reload-workflows": "reload", + } SUBCOMMAND_USAGE = { "list": "/workflows list", "run": "/workflows run ", diff --git a/cecli/helpers/command_queue.py b/cecli/helpers/command_queue.py index ad8969d71c0..957d80ee7b4 100644 --- a/cecli/helpers/command_queue.py +++ b/cecli/helpers/command_queue.py @@ -220,7 +220,9 @@ def nested_command_completions(coder, text: str) -> list: if first_space == -1: prefix = text[1:].lower() return sorted( - f"/{cmd}" for cmd in CommandRegistry.list_commands() if cmd.startswith(prefix) + f"/{cmd}" + for cmd in CommandRegistry.list_commands(include_aliases=True) + if cmd.startswith(prefix) ) inner_cmd = text[:first_space] diff --git a/cecli/io.py b/cecli/io.py index 7e08fddc35c..deeb1ea0057 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -251,7 +251,9 @@ def get_command_completions(self, document, complete_event, text, words): cmd_text = text # Pass the text (without trailing space if present) to matching_commands - matches, matched_cmd, _ = self.commands.matching_commands(cmd_text.rstrip()) + matches, matched_cmd, _ = self.commands.matching_commands( + cmd_text.rstrip(), include_aliases=True + ) if not matches: return @@ -267,7 +269,7 @@ def get_command_completions(self, document, complete_event, text, words): yield from raw_completer(document, complete_event) return - if cmd in self.commands.FULL_ARGS_COMPLETION_COMMANDS: + if self.commands.uses_full_args_completion(cmd): nested_text = text[len(words[0]) :].lstrip() candidates = self.commands.get_completions(cmd, nested_text) # The substring filter and start_position below operate on the last diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 1169df3fc84..0692e8ae317 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -2020,7 +2020,7 @@ def _get_suggestions(self, text: str) -> list[str]: else: # Use standard command completions (no file fallback) try: - if cmd_name in commands.FULL_ARGS_COMPLETION_COMMANDS: + if commands.uses_full_args_completion(cmd_name): # Nested-host commands receive the FULL text after # the command name so inner slash-command argument # completion works (e.g. "/queue /add "). @@ -2067,7 +2067,7 @@ def _get_completed_text(self, current_text: str, completion: str) -> str: parts = current_text.rsplit(maxsplit=1) commands = self.worker.coder.commands host = current_text.split(maxsplit=1)[0] - is_nested_host = host in getattr(commands, "FULL_ARGS_COMPLETION_COMMANDS", ()) + is_nested_host = commands.uses_full_args_completion(host) if is_nested_host and current_text.endswith(" "): return current_text + completion diff --git a/tests/commands/test_hooks_sessions_commands.py b/tests/commands/test_hooks_sessions_commands.py index 11967461284..71327d37bd5 100644 --- a/tests/commands/test_hooks_sessions_commands.py +++ b/tests/commands/test_hooks_sessions_commands.py @@ -140,18 +140,21 @@ def test_completions(self, monkeypatch, args, expected): class TestRegistration: - def test_dispatchers_registered_and_legacy_names_removed(self): + def test_dispatchers_registered_and_legacy_aliases_route(self): assert CommandRegistry.get_command("hooks") is HooksCommand assert CommandRegistry.get_command("sessions") is SessionsCommand - for legacy in ( - "load-hook", - "remove-hook", - "list-sessions", - "load-session", - "save-session", - ): - assert CommandRegistry.get_command(legacy) is None + alias_targets = { + "list-hooks": HooksCommand, + "load-hook": HooksCommand, + "remove-hook": HooksCommand, + "list-sessions": SessionsCommand, + "load-session": SessionsCommand, + "save-session": SessionsCommand, + } + for alias, command_class in alias_targets.items(): + assert CommandRegistry.get_command(alias) is command_class + assert CommandRegistry.resolve_alias(alias) is not None class TestFullArgsRegistration: diff --git a/tests/commands/test_mcp_skills_completions.py b/tests/commands/test_mcp_skills_completions.py index 4791d2d8591..cad3a76c54a 100644 --- a/tests/commands/test_mcp_skills_completions.py +++ b/tests/commands/test_mcp_skills_completions.py @@ -144,9 +144,10 @@ def test_io_completes_mcp_names(mcp_coder): command = list(completer.get_command_completions(None, None, "/mc", ["/mc"])) assert any(c.text == "/mcp " for c in command) - no_subcommands = list(completer.get_command_completions(None, None, "/ad", ["/ad"])) - assert no_subcommands - assert not any(c.text.endswith(" ") for c in no_subcommands) + add_completions = list(completer.get_command_completions(None, None, "/ad", ["/ad"])) + assert any(c.text == "/add" for c in add_completions) + assert not any(c.text == "/add " for c in add_completions) + assert any(c.text == "/add-queue " for c in add_completions) subcommands = list(completer.get_command_completions(None, None, "/mcp ", ["/mcp"])) assert {c.text for c in subcommands} == {"list", "load", "remove"} @@ -207,3 +208,26 @@ def test_tui_completion_request_syncs_prefix(): app.on_input_area_completion_requested(MagicMock(text="/skil")) assert input_area._completion_prefix == "/skills " + + +def test_alias_names_appear_in_command_completion(mcp_coder): + completer = _io_completer(mcp_coder) + + assert "/load-mcp" in completer.command_names + assert "/list-mcp" in completer.command_names + + +def test_alias_arguments_complete_like_their_subcommand(mcp_coder, skills_coder): + from cecli.commands.core import Commands + + mcp_commands = Commands(None, mcp_coder) + assert mcp_commands.get_completions("/load-mcp", "", coder=mcp_coder) == ["context7"] + assert mcp_commands.get_completions("/load-mcp", "con", coder=mcp_coder) == ["context7"] + + skills_commands = Commands(None, skills_coder) + assert skills_commands.get_completions("/import-skill", "", coder=skills_coder) == [ + "--global", + "pdf", + "docx", + "files/slides", + ] diff --git a/tests/commands/test_queue_commands.py b/tests/commands/test_queue_commands.py index 2d02560243e..c627aacfba0 100644 --- a/tests/commands/test_queue_commands.py +++ b/tests/commands/test_queue_commands.py @@ -483,11 +483,12 @@ def test_itc_19_get_completions_returns_indices_and_wildcard(self, populated_que assert "*" in completions def test_itc_20_commands_registered_in_registry(self): - """ITC-20: The queue dispatcher is registered and legacy names are gone.""" + """ITC-20: The queue dispatcher is registered and legacy names are aliases.""" assert CommandRegistry.get_command("queue") is QueueCommand for legacy in ("list-queue", "insert-queue", "remove-queue", "run-queue"): - assert CommandRegistry.get_command(legacy) is None + assert CommandRegistry.get_command(legacy) is QueueCommand + assert CommandRegistry.resolve_alias(legacy) is not None class TestRunQueueCommand: @@ -1163,14 +1164,14 @@ class TestCommandRegistration: """Tests for command registration in CommandRegistry.""" def test_all_queue_commands_registered(self): - """Verify the queue dispatcher is registered under its family name.""" + """Verify the dispatcher is registered under its family name.""" commands = CommandRegistry.list_commands() assert "queue" in commands - assert "list-queue" not in commands - assert "insert-queue" not in commands - assert "remove-queue" not in commands - assert "run-queue" not in commands + + aliases = CommandRegistry.list_commands(include_aliases=True) + for legacy in ("list-queue", "insert-queue", "remove-queue", "run-queue"): + assert legacy in aliases def test_command_classes_have_required_attributes(self): """Verify the dispatcher exposes its subcommands and helper modules.""" @@ -1345,7 +1346,7 @@ def test_tcp_07_io_partial_derivation(self): commands = MagicMock() commands.FULL_ARGS_COMPLETION_COMMANDS = ("/queue", "/insert-queue") # Mirror Commands.matching_commands: match the input's first word. - commands.matching_commands = lambda text: ( + commands.matching_commands = lambda text, include_aliases=False: ( ( ["/queue"] if text.startswith("/queue") @@ -1354,6 +1355,9 @@ def test_tcp_07_io_partial_derivation(self): text.split()[0], "", ) + commands.uses_full_args_completion = lambda cmd: ( + cmd in commands.FULL_ARGS_COMPLETION_COMMANDS + ) commands.get_raw_completions = lambda cmd: None def fake_get_completions(cmd, args="", coder=None): diff --git a/tests/commands/test_subcommand_usage.py b/tests/commands/test_subcommand_usage.py index 08a3962ad4d..19a07538d13 100644 --- a/tests/commands/test_subcommand_usage.py +++ b/tests/commands/test_subcommand_usage.py @@ -38,3 +38,47 @@ def test_get_command_description_returns_subcommand_usage(command_class): def test_unknown_subcommand_falls_back_to_command_description(): assert CommandRegistry.get_command_description("mcp", "bogus") == McpCommand.DESCRIPTION assert CommandRegistry.get_command_description("mcp") == McpCommand.DESCRIPTION + + +@pytest.mark.parametrize("command_class", DISPATCHERS) +def test_aliases_route_to_registered_subcommands(command_class): + aliases = getattr(command_class, "ALIASES", {}) + assert aliases + + for alias, subcommand in aliases.items(): + assert subcommand in command_class.SUBCOMMANDS + assert CommandRegistry.resolve_alias(alias) == (command_class.NORM_NAME, subcommand) + assert CommandRegistry.get_command(alias) is command_class + + +def test_command_listing_includes_aliases_on_request(): + plain = CommandRegistry.list_commands() + aliases = CommandRegistry.list_commands(include_aliases=True) + + assert "load-mcp" not in plain + assert "load-mcp" in aliases + assert set(aliases) - set(plain) == { + alias for command_class in DISPATCHERS for alias in command_class.ALIASES + } + + +def test_alias_description_uses_target_subcommand_usage(): + assert ( + CommandRegistry.get_command_description("load-mcp") == McpCommand.SUBCOMMAND_USAGE["load"] + ) + assert CommandRegistry.get_command_description("load-mcp", "ignored") == ( + McpCommand.SUBCOMMAND_USAGE["load"] + ) + + +@pytest.mark.asyncio +async def test_alias_execution_prefixes_the_subcommand(): + from unittest.mock import AsyncMock, MagicMock, patch + + io = MagicMock() + coder = MagicMock() + + with patch("cecli.commands.mcp_helpers.load.cmd_load", new_callable=AsyncMock) as cmd_load: + await CommandRegistry.execute("load-mcp", io, coder, "github") + + cmd_load.assert_awaited_once_with(io, coder, "github") From 54e67cf6b850169c7fd553720ec1aa6b737bf127 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 05:50:51 -0400 Subject: [PATCH 11/13] Add `persist` and sub agent `metadata` configuration options for worklflows --- cecli/helpers/workflows/config.py | 6 ++ cecli/helpers/workflows/models.py | 21 +++++ cecli/helpers/workflows/runner.py | 108 +++++++++++++++++++--- cecli/helpers/workflows/subagents.py | 56 +++++++++-- cecli/website/docs/config/workflows.md | 7 ++ tests/helpers/workflows/test_config.py | 33 +++++++ tests/helpers/workflows/test_runner.py | 53 +++++++++++ tests/helpers/workflows/test_subagents.py | 29 ++++++ 8 files changed, 296 insertions(+), 17 deletions(-) diff --git a/cecli/helpers/workflows/config.py b/cecli/helpers/workflows/config.py index 1a8ad8839fa..b6c04f6dc5f 100644 --- a/cecli/helpers/workflows/config.py +++ b/cecli/helpers/workflows/config.py @@ -157,6 +157,12 @@ def validate_workflow_config(wf: WorkflowConfig, raw: Optional[Dict[str, Any]] = if norm.get("hooks") is not None and not isinstance(norm["hooks"], dict): warnings.append("'hooks' is not a mapping and was ignored") + if norm.get("metadata") is not None and not isinstance(norm["metadata"], dict): + warnings.append("'metadata' is not a mapping and was ignored") + + if norm.get("persist") is not None and not isinstance(norm["persist"], bool): + warnings.append("'persist' is not a boolean") + wf.lease_timeout = _positive_number(wf.lease_timeout, "lease_timeout", where, default=30.0) _check_schedule(wf.schedule, where) diff --git a/cecli/helpers/workflows/models.py b/cecli/helpers/workflows/models.py index efc798f3bec..5a0f02fca00 100644 --- a/cecli/helpers/workflows/models.py +++ b/cecli/helpers/workflows/models.py @@ -58,6 +58,8 @@ class WorkflowConfig: hooks: Dict[str, Any] = field(default_factory=dict) skills: List[str] = field(default_factory=list) servers: List[str] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + persist: bool = False on_failure: str = "stop" catch_up: Optional[bool] = None # None: use the scheduler default source_path: Optional[str] = None @@ -80,6 +82,8 @@ def to_dict(self) -> Dict[str, Any]: "hooks": self.hooks, "skills": list(self.skills), "servers": list(self.servers), + "metadata": self.metadata, + "persist": self.persist, "on_failure": self.on_failure, "catch_up": self.catch_up, "tasks": [t.to_dict() for t in self.tasks], @@ -155,6 +159,8 @@ def from_dict(cls, raw: Dict[str, Any], source_path: Optional[str] = None) -> "W hooks=hooks if isinstance(hooks, dict) else {}, skills=[_interp(interpolate_env_vars, s) for s in data.get("skills") or []], servers=[_interp(interpolate_env_vars, s) for s in data.get("servers") or []], + metadata=(dict(data["metadata"]) if isinstance(data.get("metadata"), dict) else {}), + persist=_as_bool(data.get("persist")), on_failure=str(data.get("on_failure") or "stop"), catch_up=None if data.get("catch_up") is None else bool(data.get("catch_up")), source_path=str(source_path) if source_path else None, @@ -201,6 +207,21 @@ def error(cls, exc: BaseException, name: str = "", source: str = "scheduler") -> ) +def _as_bool(value: Any, default: bool = False) -> bool: + """Coerce a config value to a boolean without treating "false" as truthy.""" + + if value is None: + return default + + if isinstance(value, bool): + return value + + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + + return bool(value) + + def _interp(interpolate_env_vars, value: Any) -> Any: """Interpolate a single string value; pass anything else through unchanged.""" diff --git a/cecli/helpers/workflows/runner.py b/cecli/helpers/workflows/runner.py index 2cdbc815f13..6bcb5599d59 100644 --- a/cecli/helpers/workflows/runner.py +++ b/cecli/helpers/workflows/runner.py @@ -67,16 +67,12 @@ async def execute_workflow( texts = [_expand(task.prompt, wf=wf, run_id=run_id) for task in wf.tasks] - # Spawn WITHOUT a prompt so the whole queue can be staged first; the - # daemon variant runs on its own tree branch, everything else shares - # the owning session. - worker, info = await service.spawn( - wf.agent_name, - None, - parent=coder, - auto_reap=False, - independent=(source == "scheduler_daemon"), - ) + # Persistent workflows reuse their previous worker (clearing its + # conversation and queue) so they stay visible in the TUI; everything + # else spawns WITHOUT a prompt so the whole task list can be staged + # first. The daemon variant runs on its own tree branch, everything + # else shares the owning session. + worker, info = await _obtain_worker(service, coder, wf, source) for text in texts[1:]: command_queue.enqueue_prompt(worker, text) @@ -117,7 +113,8 @@ async def execute_workflow( finally: if worker is not None: _save_session(worker, coder, wf, result) - _reap_worker(service, worker) + if not wf.persist: + _reap_worker(service, worker) result.finished_at = datetime.now(timezone.utc).isoformat() logs.finish_log(handle, result) @@ -129,6 +126,24 @@ async def execute_workflow( # --------------------------------------------------------------------------- # +async def _obtain_worker(service: Any, coder: Any, wf: WorkflowConfig, source: str) -> Any: + """Reuse a persistent ``wf:`` worker when possible, else spawn a new one.""" + + if wf.persist: + info = _find_reusable_worker(service, coder, wf) + if info is not None: + await _prepare_reused_worker(info, wf) + return info.coder, info + + return await service.spawn( + wf.agent_name, + None, + parent=coder, + auto_reap=False, + independent=(source == "scheduler_daemon"), + ) + + async def _await_run(info: Any) -> Any: """Await the worker's generate task; returns ``(status, detail)``. @@ -155,6 +170,77 @@ async def _await_run(info: Any) -> Any: return "ok", "" +def _find_reusable_worker(service: Any, coder: Any, wf: WorkflowConfig) -> Any: + """Return the run's persistent worker for *wf*, if one exists.""" + + get_children = getattr(service, "get_children", None) + if get_children is None: + return None + + for info in reversed(get_children(coder)): + if getattr(info, "name", None) != wf.agent_name: + continue + + return info + + return None + + +async def _prepare_reused_worker(info: Any, wf: WorkflowConfig) -> None: + """Wait for an in-flight run, then reset *info*'s conversation and queue.""" + import asyncio + + from cecli.helpers import command_queue + from cecli.helpers.conversation.service import ConversationService + + task = getattr(info, "generate_task", None) + if task is not None and not task.done(): + try: + await task + except asyncio.CancelledError: + pass + except Exception: + # The reused worker is reset below regardless of how the prior run + # ended; its outcome was already recorded by that run. + pass + + worker = info.coder + + command_queue.clear_queue(worker) + worker._queue_counter = 0 + worker._processing_queue = False + worker._active_queue_prompt = None + worker._drain_queue_after_command = False + + # File context belongs to the run that added it; start the next one with + # the same empty file lists a freshly spawned worker would have. + worker.abs_fnames = set() + worker.abs_read_only_fnames = set() + worker.abs_read_only_stubs_fnames = set() + + info.summary = None + info.error = None + info.cancel_requested = False + info.auto_reap = False + + chunks = ConversationService.get_chunks(worker) + chunks.flush_removals() + ConversationService.get_manager(worker).reset() + ConversationService.get_files(worker).reset() + ConversationService.get_manager(worker).initialize(reformat=True) + chunks.reset_clear_count() + chunks.reset_message_tracker() + chunks.add_system_message(_worker_prompt(wf)) + + +def _worker_prompt(wf: WorkflowConfig) -> str: + """System prompt of the registered ``wf:`` agent that runs *wf*.""" + from cecli.helpers.agents.service import AgentService + + config = AgentService.get_registry().get(wf.agent_name) + return getattr(config, "prompt", "") or "" + + def _task_results(wf, outcome, detail, executed_tail) -> List[TaskResult]: """Derive per-task outcomes from the overall run result and queue position.""" results: List[TaskResult] = [] diff --git a/cecli/helpers/workflows/subagents.py b/cecli/helpers/workflows/subagents.py index 53c9d9a3be3..09e82dd6132 100644 --- a/cecli/helpers/workflows/subagents.py +++ b/cecli/helpers/workflows/subagents.py @@ -3,8 +3,10 @@ Each *enabled* workflow becomes a ``wf:{name}`` worker sub-agent, mirroring the ``ws:`` workspace registration (:mod:`cecli.helpers.workspaces.subagents`). The declarative ``skills`` / ``servers`` / ``root`` fields map onto the -sub-agent's ``metadata["agent-config"]``; ``skip_cli_confirmations`` is always -forced because workflow workers run unattended. +sub-agent's ``metadata["agent-config"]``; a workflow ``metadata`` block supplies +workspace-style overrides (``model`` / ``hooks`` / ``auto_reap`` plus arbitrary +metadata), and ``skip_cli_confirmations`` is always forced because workflow +workers run unattended. ``persist`` workers are never auto-reaped. The most recent configs are cached module-level (``_workflow_configs_by_agent`` including disabled workflows) so ``/workflows`` and session restore can look @@ -58,11 +60,53 @@ def register_workflow_subagents(workflow_configs) -> List[str]: agent_config.setdefault("skills_includelist", []) if wf.servers: agent_config["servers_includelist"] = [s.lower() for s in wf.servers] - # workflow workers run unattended: never block on an interactive confirm + + # A workflow may supply its own sub-agent setup under ``metadata``, + # matching workspace projects and .md definitions. + wf_metadata = dict(wf.metadata or {}) + # ``root``, ``name`` and ``description`` are derived from the workflow + # definition and cannot be overridden by the metadata block. + wf_metadata.pop("root", None) + wf_metadata.pop("name", None) + wf_metadata.pop("description", None) + config_keys = {"model", "hooks", "auto_reap"} + meta_agent_config = wf_metadata.pop("agent-config", None) + if meta_agent_config is None: + # from_dict normalizes nested keys, so ``agent-config`` arrives as + # ``agent_config``. + meta_agent_config = wf_metadata.pop("agent_config", None) + for key, value in wf_metadata.items(): + if key in config_keys: + continue + metadata[key] = value + + model = ( + wf_metadata["model"] + if "model" in wf_metadata + else (wf.model or (worker.model if worker else None)) + ) + meta_hooks = wf_metadata.get("hooks") + if isinstance(meta_hooks, dict): + hooks = dict(meta_hooks) + elif wf.hooks: + hooks = dict(wf.hooks) + else: + hooks = dict(worker.hooks) if worker else {} + + auto_reap = wf_metadata["auto_reap"] if "auto_reap" in wf_metadata else False + if wf.persist: + # Persistent workers must survive the run so the next one can reuse them. + auto_reap = False + + if isinstance(meta_agent_config, dict): + agent_config.update(meta_agent_config) + + # workflows run unattended: never block on an interactive confirm agent_config["skip_cli_confirmations"] = True # ...and are detached: their results never surface in the parent's # conversation (background operations stay out of its context). agent_config["detached"] = True + if wf.root: metadata["root"] = str(Path(wf.root).expanduser().resolve()) @@ -77,9 +121,9 @@ def register_workflow_subagents(workflow_configs) -> List[str]: SubAgentConfig( name=agent_name, prompt=worker.prompt if worker else "", - model=wf.model or (worker.model if worker else None), - hooks=dict(wf.hooks) if wf.hooks else (dict(worker.hooks) if worker else {}), - auto_reap=False, # persist for the run; the runner reaps at the end + model=model, + hooks=hooks, + auto_reap=auto_reap, # persist for the run; the runner reaps at the end metadata=metadata, ), ) diff --git a/cecli/website/docs/config/workflows.md b/cecli/website/docs/config/workflows.md index 6da9a679a04..24711f8d05f 100644 --- a/cecli/website/docs/config/workflows.md +++ b/cecli/website/docs/config/workflows.md @@ -45,6 +45,10 @@ hooks: # optional, same schema as Hooks skills: [gitlab-ci] # optional -> preloaded as skills_init servers: [gitlab] # optional -> active MCP servers (lowercased) on_failure: stop # stop | continue +persist: true # keep the worker in the TUI and reuse it next run +metadata: # optional sub-agent overrides (see below) + agent-config: + allow_nested_delegation: false tasks: - name: attach-gitlab prompt: /mcp load gitlab @@ -73,6 +77,8 @@ Field meanings: | `hooks` | {} | Per-workflow hooks (start/end/pre_tool/…); run on the worker | | `skills` | [] | Skills loaded into the worker's context at start | | `servers` | [] | MCP servers made available to the worker | +| `metadata` | {} | Sub-agent overrides, matching workspace project metadata: `model`, `hooks` and `auto_reap` map to the sub-agent config fields, an `agent-config` mapping is merged on top of the declarative `skills`/`servers`, and any other key is merged into the sub-agent metadata. `name`, `description` and `root` always come from the workflow definition | +| `persist` | false | Keep the worker sub-agent (and its TUI container) after the run finishes. The next run reuses the same worker after clearing its conversation, file context and prompt queue. Persistent workers are never auto-reaped | | `on_failure` | `stop` | What to do after a task errors: `stop` drops the remaining tasks, `continue` runs them anyway | | `catch_up` | scheduler default | Per-workflow override of the [scheduler's](scheduler.html) `catch_up` (replay of slots missed while cecli was closed) | @@ -92,6 +98,7 @@ Run-time placeholders are expanded in task prompts in addition to load-time env 2. The task prompts are queued to that worker **in order**, using the same prompt queue as `/queue`. Each prompt is processed to completion before the next. 3. If a task errors, the remaining queued prompts are dropped according to `on_failure` (or the failing task's `continue_on_error`), then the worker is reaped. (Caveat: errors that abort the queue-drain chain behave like `stop` even under `continue_on_error`, since the remaining prompts never get a chance to drain; errors handled inside a prompt continue normally.) 4. The run is **detached**: a workflow worker never injects summaries or status into the parent's conversation (background operations stay out of its context). A finished `/workflows run` is announced once in the terminal only; the durable record lives in the run log and outputs. +5. With `persist: true`, the worker is not reaped when the run finishes: it stays in the TUI and the next run of the same workflow reuses it. Before staging the new tasks, the runner waits for any in-flight run, clears the worker's conversation (re-adding its system prompt) and file context, resets the prompt queue and per-run status, and then runs the new task list on the same agent. Because a run is a sub-agent, it doesn't block the main conversation — watch it with `/agent-tree` or the TUI sub-agent pane. Every run also appends a JSON-lines audit log to `~/.cecli/workflows/runs//..jsonl`: a `start` line (with the definition hash), one `task` line per task, and a `finish` line. Before the worker is reaped, its full session is archived to `~/.cecli/workflows/outputs///session.json` using the standard session format (encrypted when session encryption is enabled) — so every run, including replayed or tool-less replies, is completely reviewable on disk without ever touching the parent's context. diff --git a/tests/helpers/workflows/test_config.py b/tests/helpers/workflows/test_config.py index 91a31b9b847..a5c82cb9928 100644 --- a/tests/helpers/workflows/test_config.py +++ b/tests/helpers/workflows/test_config.py @@ -624,3 +624,36 @@ def test_resolve_returns_raw_precedence_order(isolated): assert entries[0][1] is None assert entries[0][2] == "inline --workflows value" assert entries[2][1] == str(isolated["wdir"] / "a_home.yml") + + +# ── persist + metadata ──────────────────────────────────────────────────── + + +def test_persist_and_metadata_are_parsed(isolated): + _home_workflow( + isolated["wdir"], + "persist.yml", + "name: persistent\n" + "persist: true\n" + "metadata:\n" + " model: meta-model\n" + " agent-config:\n" + " skills_init: [meta-skill]\n" + "tasks:\n" + " - prompt: p\n", + ) + + wf = load_workflows()[0] + + assert wf.persist is True + assert wf.metadata["model"] == "meta-model" + assert wf.metadata["agent_config"]["skills_init"] == ["meta-skill"] + assert wf.to_dict()["persist"] is True + + +def test_persist_string_false_is_not_truthy(): + wf = models.WorkflowConfig.from_dict( + {"name": "p", "persist": "false", "tasks": [{"prompt": "x"}]} + ) + + assert wf.persist is False diff --git a/tests/helpers/workflows/test_runner.py b/tests/helpers/workflows/test_runner.py index d14748198b7..6105f365d8d 100644 --- a/tests/helpers/workflows/test_runner.py +++ b/tests/helpers/workflows/test_runner.py @@ -45,6 +45,8 @@ def __init__(self, worker, gen): self.gen = gen self.calls = [] self.info = SubAgentInfo(name="wf:demo", coder=worker, parent_uuid="primary-uuid") + self.sub_agents = {} + self.children = [] self.reaped = [] async def spawn(self, name, prompt=None, parent=None, auto_reap=None, independent=False): @@ -77,6 +79,9 @@ async def _run(): info.generate_task = asyncio.create_task(_run()) + def get_children(self, coder): + return list(self.children) + def _cleanup_sub_agent(self, uuid): self.reaped.append(uuid) @@ -423,3 +428,51 @@ async def test_session_archive_failure_is_not_fatal(coder): result = await _execute(service, wf, coder) assert result.status == "ok" + + +@pytest.mark.asyncio +async def test_persist_worker_is_not_reaped(coder): + worker = make_worker() + worker.received = [] + service = FakeService(worker, _drain) + wf = _wf(TaskConfig(prompt="x"), persist=True) + + result = await _execute(service, wf, coder) + + assert result.status == "ok" + assert service.reaped == [] + + +@pytest.mark.asyncio +async def test_persist_reuses_existing_worker(coder): + existing = make_worker() + existing.received = [] + existing.prompt_queue = [{"id": "1", "text": "stale", "timestamp": 0}] + info = SubAgentInfo( + name="wf:demo", + coder=existing, + parent_uuid="primary-uuid", + status=SubAgentStatus.FINISHED, + ) + + service = FakeService(make_worker(), _drain) + service.children = [info] + wf = _wf(TaskConfig(prompt="fresh"), TaskConfig(prompt="tail"), persist=True) + + with ( + patch("cecli.helpers.conversation.service.ConversationService.get_chunks") as get_chunks, + patch("cecli.helpers.conversation.service.ConversationService.get_manager") as get_manager, + patch("cecli.helpers.conversation.service.ConversationService.get_files") as get_files, + ): + result = await _execute(service, wf, coder) + + assert result.status == "ok" + assert not any(call[0] == "spawn" for call in service.calls) + assert existing.received == ["tail"] + assert any(call[0] == "start" and call[1] == "fresh" for call in service.calls) + assert existing.prompt_queue == [] + get_manager.return_value.reset.assert_called_once() + get_manager.return_value.initialize.assert_called_once() + get_files.return_value.reset.assert_called_once() + get_chunks.return_value.add_system_message.assert_called_once() + assert service.reaped == [] diff --git a/tests/helpers/workflows/test_subagents.py b/tests/helpers/workflows/test_subagents.py index af9d4399629..164a32ed669 100644 --- a/tests/helpers/workflows/test_subagents.py +++ b/tests/helpers/workflows/test_subagents.py @@ -154,3 +154,32 @@ def test_worker_prompt_inherited(): assert cfg.prompt == worker.prompt # (the built-in worker default may legitimately have an empty body) assert cfg.auto_reap is False + + +# ---------------------------------------------------------------- case 15 -- + + +def test_workflow_metadata_overrides_subagent_config(): + wf = _wf( + servers=["GitLab"], + persist=True, + metadata={ + "model": "meta-model", + "auto_reap": True, + "custom": "value", + "agent-config": {"skills_init": ["meta-skill"]}, + }, + ) + register_workflow_subagents([wf]) + cfg = AgentService.get_registry()["wf:check-pipeline"] + + assert cfg.model == "meta-model" + # persist workers must never be auto-reaped + assert cfg.auto_reap is False + assert cfg.metadata["custom"] == "value" + + agent_config = cfg.metadata["agent-config"] + assert agent_config["skills_init"] == ["meta-skill"] + assert agent_config["servers_includelist"] == ["gitlab"] + assert agent_config["skip_cli_confirmations"] is True + assert agent_config["detached"] is True From f92419cffbf091d7c0868205bd97ff4fb310b8c1 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 05:58:25 -0400 Subject: [PATCH 12/13] Update tests for sub commands --- tests/basic/test_io.py | 2 +- tests/tui/test_floating_panels.py | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/basic/test_io.py b/tests/basic/test_io.py index 7e9b51099b1..883e6d08588 100644 --- a/tests/basic/test_io.py +++ b/tests/basic/test_io.py @@ -85,7 +85,7 @@ def test_autocompleter_get_command_completions(self): commands = MagicMock() commands.has_subcommands.return_value = False commands.get_commands.return_value = ["/help", "/add", "/drop"] - commands.matching_commands.side_effect = lambda inp: ( + commands.matching_commands.side_effect = lambda inp, include_aliases=True: ( [cmd for cmd in commands.get_commands() if cmd.startswith(inp.strip().split()[0])], inp.strip().split()[0], " ".join(inp.strip().split()[1:]), diff --git a/tests/tui/test_floating_panels.py b/tests/tui/test_floating_panels.py index 33ade48386a..9d2cb799c8e 100644 --- a/tests/tui/test_floating_panels.py +++ b/tests/tui/test_floating_panels.py @@ -140,11 +140,22 @@ async def _run_panel_checks(): ("servers", "github", False), ] - assert panels._command_class("subagents", False).__name__ == "SpawnAgentCommand" - assert panels._command_class("skills", True).__name__ == "RemoveSkillCommand" - assert panels._command_class("skills", False).__name__ == "LoadSkillCommand" - assert panels._command_class("servers", True).__name__ == "RemoveMcpCommand" - assert panels._command_class("servers", False).__name__ == "LoadMcpCommand" + command_cls, command_args = panels._command_for("subagents", "worker", False) + assert command_cls.__name__ == "SpawnAgentCommand" + assert command_args == "worker" + + for category, active, expected_args in [ + ("skills", True, "remove pdf"), + ("skills", False, "load pdf"), + ("servers", True, "remove github"), + ("servers", False, "load github"), + ]: + command_cls, command_args = panels._command_for( + category, "pdf" if category == "skills" else "github", active + ) + expected_cls = "SkillsCommand" if category == "skills" else "McpCommand" + assert command_cls.__name__ == expected_cls + assert command_args == expected_args panels._collect_skills = lambda coder: [("z", True), ("a", True), ("b", False)] panels.refresh_data() From 0b8971c7cc1b3f98fa8aa48910f9a0722cb7e485 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 07:15:44 -0400 Subject: [PATCH 13/13] Update key redaction heuristics --- cecli/helpers/key_detection.py | 223 ++++++++++++++++++++++++++-- tests/helpers/test_key_detection.py | 113 ++++++++++++++ 2 files changed, 321 insertions(+), 15 deletions(-) diff --git a/cecli/helpers/key_detection.py b/cecli/helpers/key_detection.py index 6f2757fec56..8c3cb73fbc0 100644 --- a/cecli/helpers/key_detection.py +++ b/cecli/helpers/key_detection.py @@ -1,6 +1,8 @@ +import math import os import re import threading +from collections import Counter from typing import Any, Iterable, List, Mapping, Optional, Set # A run of key-safe characters (letters, digits and -_. only, so never any @@ -21,26 +23,65 @@ # Those are not secrets themselves, so they must never be registered. _ENV_REF_RE = re.compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$") -# Name words that mark a value as a credential, and take precedence over -# BENIGN_NAME_WORDS. "pwd" is deliberately absent: PWD holds the working dir, -# the rest are matched as substrings instead (see SECRET_NAME_SUBSTRINGS). +# Values that look like filesystem paths are identifiers, not secrets, so they +# are never registered (a key may live at a path, but the path is not the key). +_URL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://") +_WINDOWS_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]") +_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_.~-]+$") +_VERSION_SUFFIX_RE = re.compile(r"[_-]?v?\d+$") + +# Name words that mark a value as a credential when they end the name, and +# take precedence over BENIGN_NAME_WORDS. "pwd" is deliberately absent: PWD +# holds the working dir, so it is benign instead (see BENIGN_NAME_WORDS). Words +# only count when they end the name (see SECRET_NAME_SUFFIXES). SECRET_NAME_WORDS = { "apikey", + "apikeys", "credential", "credentials", "key", "keys", "passwd", "password", + "passwords", "secret", "secrets", "token", "tokens", } -# Fragments that mark a name as credential-named wherever they appear in it, so -# names whose credential word is not separated by punctuation ("MYTOKEN"). -SECRET_NAME_SUBSTRINGS = ("token", "passwd", "password") +SECRET_NAME_SUFFIXES = tuple(sorted(SECRET_NAME_WORDS)) + +# Separators that make a trailing credential word unambiguous. Without one, the +# word is part of a larger token ("MONKEY", "sortKey") and must be confirmed by +# the value itself (see _is_ambiguous_secret_name). +_SUFFIX_SEPARATORS = ("_", "-", ".", "/", "\\", " ") + +_SEPARATED_SECRET_SUFFIXES = tuple( + separator + suffix for separator in _SUFFIX_SEPARATORS for suffix in SECRET_NAME_SUFFIXES +) + +# A name that contains a credential word that is not a suffix ("KEY_DESCRIPTION_V2") +# is not trusted on its own: the value must also look key-like, measured as +# Shannon entropy in bits per character. Passwords are allowed a lower bar +# because they are often human-chosen rather than random. +DEFAULT_CREDENTIAL_ENTROPY = 3.2 + +CREDENTIAL_ENTROPY_THRESHOLDS = { + "apikey": 3.2, + "apikeys": 3.2, + "credential": 3.0, + "credentials": 3.0, + "key": 3.2, + "keys": 3.2, + "passwd": 2.5, + "password": 2.5, + "passwords": 2.5, + "secret": 3.0, + "secrets": 3.0, + "token": 3.2, + "tokens": 3.2, +} # Name words that mark a value as an identifier (paths, models, session ids, # hostnames). These are never scanned for key-like tokens: config/env entries @@ -57,6 +98,7 @@ "domain", "editor", "encoding", + "env", "exe", "file", "files", @@ -73,11 +115,14 @@ "model", "models", "name", + "oldpwd", "pager", "path", "paths", "pid", + "prefix", "profile", + "pwd", "release", "repo", "root", @@ -91,7 +136,13 @@ "urls", "user", "uuid", + "venv", "version", + "virtualenv", + "workspace", + "workspaces", + "worktree", + "worktrees", } _api_keys: List[str] = [] @@ -107,11 +158,16 @@ def detect_api_keys( Every value is classified by the name it is stored under: - - credential-named (contains "key", "token", "password", "secret", ...): + - credential-named (ends with "key", "token", "password", "secret", ...): the whole value is taken as a candidate, since such values may contain punctuation that a key-like token regex cannot span. - - identifier-named (see BENIGN_NAME_WORDS): skipped, because these - legitimately hold long non-secret strings. + - containing a credential word, as a word or inside a longer token (e.g. + "KEY_DESCRIPTION_V2" or "sortKey"): the whole value is taken only when + it also looks key-like (length, letter/digit mix and entropy above the + threshold for that word). + - identifier-named (see BENIGN_NAME_WORDS) or path-shaped: not + registered in full, because these legitimately hold long non-secret + strings; any key-like tokens embedded in the value are still registered. - anything else: scanned for key-like tokens, i.e. runs of ``MIN_KEY_LENGTH``+ characters of ``[A-Za-z0-9-_.]``. @@ -196,7 +252,6 @@ def redact_api_keys(value: Any) -> Any: if isinstance(value, Mapping): return _redact_mapping(value) - return {key: redact_api_keys(item) for key, item in value.items()} if isinstance(value, list): return _redact_seq(value, list) @@ -245,6 +300,15 @@ def _scan_entry(name: Any, value: Any, found: Set[str], expand: bool, forced: bo _register_secret_value(text, found) return + if _contains_secret_word(name) or _is_ambiguous_secret_name(name): + if _looks_like_key_value(text, name): + _register_secret_value(text, found) + return + + if _looks_path_ish(text.strip()): + _register_embedded_keys(text, found) + return + if _is_benign_name(name): return @@ -252,24 +316,44 @@ def _scan_entry(name: Any, value: Any, found: Set[str], expand: bool, forced: bo def _register_secret_value(value: str, found: Set[str]) -> None: - """Take a credential-named value in full, plus any key-like tokens in it.""" + """Take a credential-named value in full, plus any key-like tokens in it. + + Path-shaped values are not registered in full, but any key-like tokens + embedded in them still are. + """ stripped = value.strip() if len(stripped) < MIN_VALUE_LENGTH or _ENV_REF_RE.match(stripped): return + if _looks_like_path(stripped): + _register_embedded_keys(stripped, found) + return + found.add(stripped) found.update(_KEY_LIKE_RE.findall(stripped)) def _is_secret_name(name: Any) -> bool: - """True if the name itself says this value is a credential.""" - lowered = str(name).lower() + """True if the name unambiguously ends with a credential word.""" + lowered = _strip_version_suffix(name) - if "key" in lowered or any(word in lowered for word in SECRET_NAME_SUBSTRINGS): + if lowered in SECRET_NAME_WORDS: return True - return bool(_name_words(name) & SECRET_NAME_WORDS) + return lowered.endswith(_SEPARATED_SECRET_SUFFIXES) + + +def _is_ambiguous_secret_name(name: Any) -> bool: + """True if the name ends with a credential word inside a longer token.""" + lowered = _strip_version_suffix(name) + + return lowered.endswith(SECRET_NAME_SUFFIXES) and not _is_secret_name(name) + + +def _strip_version_suffix(name: Any) -> str: + """Lowercase ``name`` and drop a trailing version like ``2`` or ``_V2``.""" + return _VERSION_SUFFIX_RE.sub("", str(name).strip().lower()) def _is_benign_name(name: Any) -> bool: @@ -291,6 +375,115 @@ def _name_words(name: Any) -> Set[str]: return words +def _contains_secret_word(name: Any) -> bool: + """True if a credential word appears as a separate word in the name.""" + return bool(_name_words(name) & SECRET_NAME_WORDS) + + +def _looks_like_key_value(value: str, name: Any) -> bool: + """True if a value under a containing credential name looks like a key. + + Best-effort: requires ``MIN_KEY_LENGTH`` characters, letters and digits and + no whitespace, so short or all-alpha passphrases under ambiguous names are + intentionally not detected. + """ + stripped = value.strip() + + if len(stripped) < MIN_KEY_LENGTH or _looks_like_path(stripped): + return False + + if any(char.isspace() for char in stripped): + return False + + has_alpha = any(char.isalpha() for char in stripped) + has_digit = any(char.isdigit() for char in stripped) + + if not has_alpha or not has_digit: + return False + + return _shannon_entropy(stripped) >= _entropy_threshold(name) + + +def _entropy_threshold(name: Any) -> float: + """Return the entropy bar for the credential words in ``name``.""" + words = _name_words(name) & set(CREDENTIAL_ENTROPY_THRESHOLDS) + + if not words: + return DEFAULT_CREDENTIAL_ENTROPY + + return min(CREDENTIAL_ENTROPY_THRESHOLDS[word] for word in words) + + +def _looks_like_path(value: str) -> bool: + """True if ``value`` looks like a filesystem path rather than a secret.""" + if _URL_RE.match(value): + return False + + if value.startswith(("/", "~", "./", "../")): + return True + + if _WINDOWS_PATH_RE.match(value) or value.startswith("\\\\"): + return True + + if "\\" in value: + return _has_path_extension(value, "\\") + + if "/" not in value: + return False + + return _has_path_extension(value, "/") + + +def _has_path_extension(value: str, separator: str) -> bool: + """True if ``value`` has 2+ ``separator`` segments and a dotted segment.""" + segments = [segment for segment in value.split(separator) if segment] + + return len(segments) > 1 and any("." in segment for segment in segments) + + +def _looks_path_ish(value: str) -> bool: + """True if ``value`` looks like any path, including extensionless relative.""" + if _looks_like_path(value): + return True + + if "/" in value: + return _all_path_segments(value.split("/")) + + if "\\" in value: + return _all_path_segments(value.split("\\")) + + return False + + +def _all_path_segments(segments: Iterable[str]) -> bool: + """True if ``segments`` form a relative path and not a token with slashes.""" + parts = [segment for segment in segments if segment] + + return len(parts) > 1 and all(_PATH_SEGMENT_RE.match(segment) for segment in parts) + + +def _register_embedded_keys(value: str, found: Set[str]) -> None: + """Register key-like tokens in a path-shaped value, skipping path chunks. + + A slash-containing token under a generic name is indistinguishable from a + path segment and is skipped; credential-named values are registered in full + before this helper is reached. + """ + for chunk in value.split(): + if _looks_path_ish(chunk): + continue + + found.update(_KEY_LIKE_RE.findall(chunk)) + + +def _shannon_entropy(value: str) -> float: + """Return the Shannon entropy of ``value`` in bits per character.""" + counts = Counter(value) + length = len(value) + + return -sum((count / length) * math.log2(count / length) for count in counts.values()) + + def _redact_mapping(mapping: Mapping) -> Mapping: """Apply redaction to every value, keeping the mapping when unchanged.""" items = {} diff --git a/tests/helpers/test_key_detection.py b/tests/helpers/test_key_detection.py index 24c82f721e3..6a733cbef19 100644 --- a/tests/helpers/test_key_detection.py +++ b/tests/helpers/test_key_detection.py @@ -49,6 +49,119 @@ def test_detects_credential_words_that_are_not_separate_name_words(): assert key_detection.detect_api_keys(environ=environ) == [FAKE_TOKEN] +def test_ignores_names_where_credential_word_is_not_a_suffix(): + environ = { + "TOKENIZER": f"Bearer {FAKE_TOKEN}", + "KEYBOARD_LAYOUT": f"Bearer {FAKE_TOKEN}", + "SSH_KEY_PATH": "/home/some/repo/lower_case-4/file.txt", + } + + assert key_detection.detect_api_keys(environ=environ) == [FAKE_TOKEN] + + +def test_ignores_path_shaped_identifiers(): + environ = { + "PWD": "/home/some/repo/my_project_worktree_checkout-4", + "PYTHONPATH": "/home/some/project_with_a_really_long_name-4/lib", + "SOME_PROVIDER": "parent_with_a_very_long_name-4/lower_case-4/file.txt", + "SOME_OTHER": "parent_with_a_very_long_name-4/lower_case_checkout", + "CLIENT_SECRET": "/home/some/project_with_a_really_long_name-4/secret.txt", + } + + assert key_detection.detect_api_keys(environ=environ) == [] + + +def test_contains_credential_word_requires_a_key_like_value(): + environ = { + "API_KEY_V2": FAKE_KEY, + "KEY_DESCRIPTION_V2": "ordinary human readable words", + "KEY_LOW_DIVERSITY_V2": "abc123 abc123 abc123 abc123", + "KEY_LOCATION_V2": "parent_with_a_very_long_name-4/file.txt", + } + + assert key_detection.detect_api_keys(environ=environ) == [FAKE_KEY] + + +def test_detects_keys_embedded_in_path_shaped_values(): + environ = {"SOME_PROVIDER": f"/usr/bin/foo {FAKE_KEY}"} + + assert key_detection.detect_api_keys(environ=environ) == [FAKE_KEY] + + +def test_ambiguous_suffix_names_require_key_like_values(): + environ = { + "MONKEY": "ordinary-value", + "TURKEY": "ordinary-value", + "sortKey": "ordinary-value", + "lookupKey": "ordinary-value", + "HOTKEYS": "ordinary-value", + "MYTOKEN2": "ordinary-value", + } + merged = {"hotkeys": {"copy": "ordinary-value"}} + + assert key_detection.detect_api_keys(environ=environ) == [] + assert key_detection.detect_api_keys(merged, environ={}) == [] + + +def test_entropy_gate_rejects_whitespace_and_prose(): + environ = {"sortKey": "rotate this api key every90 days"} + + assert key_detection.detect_api_keys(environ=environ) == [] + + +def test_path_detection_handles_urls_windows_and_relative_paths(): + looks_like_path = key_detection._looks_like_path + + assert not looks_like_path("https://example.com/a/b") + assert looks_like_path(r"C:\Users\some\project_with_a_long_name-4") + assert looks_like_path(r"\\server\share\project_with_a_long_name-4") + assert looks_like_path("parent_with_a_long_name-4/lower_case_checkout/index.txt") + assert not looks_like_path("parent_with_a_long_name-4/lower_case_checkout") + assert key_detection._looks_path_ish("parent_with_a_long_name-4/lower_case_checkout") + assert not looks_like_path("AbCd/EfGh12/IjKl34/MnOp56") + assert not looks_like_path(r"abc\def") + assert not looks_like_path(FAKE_KEY) + + +def test_entropy_thresholds_cover_secret_words(): + assert set(key_detection.SECRET_NAME_SUFFIXES) <= set( + key_detection.CREDENTIAL_ENTROPY_THRESHOLDS + ) + + +def test_keeps_slash_containing_secrets(): + slash_key = "AbCd/EfGh12/IjKl34/MnOp56/QrSt78" + environ = {"API_KEY": slash_key, "CLIENT_SECRET": slash_key} + merged = {"api_key": "abcd1234/efgh5678/ijkl9012mnop3456qrst"} + + assert key_detection.detect_api_keys(environ=environ) == [slash_key] + assert key_detection.detect_api_keys(merged, environ={}) == [ + "abcd1234/efgh5678/ijkl9012mnop3456qrst" + ] + + +def test_ambiguous_suffix_names_still_detect_key_like_values(): + environ = {"authToken": FAKE_TOKEN, "sortKey": FAKE_KEY, "MYTOKEN2": FAKE_TOKEN} + + assert key_detection.detect_api_keys(environ=environ) == sorted({FAKE_KEY, FAKE_TOKEN}) + + +def test_credential_words_take_precedence_over_benign_words(): + environ = {"KEY_ID": FAKE_KEY, "TOKEN_NAME": FAKE_TOKEN} + + assert key_detection.detect_api_keys(environ=environ) == sorted({FAKE_KEY, FAKE_TOKEN}) + + +def test_version_suffixed_unambiguous_names_are_trusted(): + environ = { + "SECRET_KEY_1": "p@ss:w0rd!", + "API_KEY_1": "pa:ssw0rd", + "DB_PASSWORD_2": "hunter2hunter2", + } + + assert set(key_detection.detect_api_keys(environ=environ)) == set(environ.values()) + + def test_ignores_short_values_of_key_named_env_vars(): environ = {"SOME_FLAG_KEY": "true"}