From 43df50996841c3db8936ed6c6f23fbd1ee2b7324 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 24 Sep 2026 02:24:54 -0500 Subject: [PATCH 01/28] fix: honor reduced motion in the TUI --- cecli/tui/__init__.py | 1 + cecli/tui/io.py | 8 +++++ cecli/tui/widgets/footer.py | 9 +++-- cecli/tui/widgets/input_container.py | 3 +- cecli/website/docs/config/tui.md | 12 +++++++ tests/basic/test_spinner.py | 15 +++++++++ tests/tui/test_motion.py | 50 ++++++++++++++++++++++++++++ 7 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/tui/test_motion.py diff --git a/cecli/tui/__init__.py b/cecli/tui/__init__.py index 35fd7a0129c..76d8d024ab3 100644 --- a/cecli/tui/__init__.py +++ b/cecli/tui/__init__.py @@ -58,6 +58,7 @@ def create_tui_io(args, editing_mode): notifications_command=args.notifications_command, notification_bell=args.notification_bell, verbose=args.verbose, + show_spinner=args.spinner, ) return io, output_queue, input_queue diff --git a/cecli/tui/io.py b/cecli/tui/io.py index 9db636bbb9c..b59f4700368 100644 --- a/cecli/tui/io.py +++ b/cecli/tui/io.py @@ -345,6 +345,8 @@ def start_spinner(self, text, update_last_text=True, **kwargs): update_last_text: Whether to update last_spinner_text coder_uuid: Optional uuid string to include in the message """ + if not self.spinner_active: + return coder_uuid = kwargs.get("coder_uuid", None) # Call parent to maintain state super().start_spinner(text, update_last_text) @@ -377,6 +379,8 @@ def update_spinner(self, text, **kwargs): text: New spinner text coder_uuid: Optional uuid string to include in the message """ + if not self.spinner_active: + return coder_uuid = kwargs.get("coder_uuid", None) # Call parent super().update_spinner(text) @@ -399,6 +403,8 @@ def update_spinner_suffix(self, text=None, **kwargs): text: New spinner suffix text coder_uuid: Optional uuid string to include in the message """ + if not self.spinner_active: + return coder_uuid = kwargs.get("coder_uuid", None) # Call parent super().update_spinner_suffix(text) @@ -419,6 +425,8 @@ def stop_spinner(self, **kwargs): coder_uuid = kwargs.get("coder_uuid", None) # Call parent super().stop_spinner() + if not self.spinner_active: + return # Send to TUI self.output_queue.put({"type": "spinner", "action": "stop", "coder_uuid": coder_uuid}) diff --git a/cecli/tui/widgets/footer.py b/cecli/tui/widgets/footer.py index 6ea1983ddb4..76424125add 100644 --- a/cecli/tui/widgets/footer.py +++ b/cecli/tui/widgets/footer.py @@ -51,7 +51,8 @@ def __init__( def on_mount(self): """Start spinner animation interval.""" - self._spinner_interval = self.set_interval(0.1, self._animate_spinner) + if self.app.animation_level != "none": + self._spinner_interval = self.set_interval(0.1, self._animate_spinner) def _animate_spinner(self): """Animate the spinner character.""" @@ -97,7 +98,11 @@ def render(self) -> Text: left = Text() if self.spinner_visible: - spinner_char = self._spinner_chars[self._spinner_frame] + spinner_char = ( + "•" + if self.app.animation_level == "none" + else self._spinner_chars[self._spinner_frame] + ) left.append(f"{spinner_char} ") if self.agent_name: left.append(f"({self.agent_name}) ") diff --git a/cecli/tui/widgets/input_container.py b/cecli/tui/widgets/input_container.py index ac2f002451d..2ef9c097494 100644 --- a/cecli/tui/widgets/input_container.py +++ b/cecli/tui/widgets/input_container.py @@ -20,7 +20,8 @@ def on_mount(self): def _refresh_sub_agents(self): """Re-render the border title with current sub-agent status.""" - self.show_squares = not self.show_squares + if self.app.animation_level != "none": + self.show_squares = not self.show_squares self.update_mode(self.coder_mode) def update_mode(self, mode: str): diff --git a/cecli/website/docs/config/tui.md b/cecli/website/docs/config/tui.md index c444dd14bbf..a26f04e2353 100644 --- a/cecli/website/docs/config/tui.md +++ b/cecli/website/docs/config/tui.md @@ -25,6 +25,18 @@ The TUI can be configured directly in the relevant config.json file or with JSON tui: true ``` +### Reduce Motion + +Set Textual's native animation level in your shell or `.env` file to keep the +TUI static while preserving status updates: + +```dotenv +TEXTUAL_ANIMATIONS=none +``` + +To hide only the activity spinner, use `--no-spinner` or set `spinner: false` +in a Cecli configuration file. + ### Complete Configuration Example The base theme is pretty nice but if you want different colors and key bindings, you can specify them in the configuration files (`~/.cecli/conf.yml` or `.cecli.conf.yml`): diff --git a/tests/basic/test_spinner.py b/tests/basic/test_spinner.py index 07d345b8f85..c1758a3c1f4 100644 --- a/tests/basic/test_spinner.py +++ b/tests/basic/test_spinner.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock import pytest +from prompt_toolkit.enums import EditingMode @pytest.fixture @@ -84,3 +85,17 @@ def test_io_start_spinner_noop_when_disabled(self): io.start_spinner("Awaiting Confirmation...") assert io.fallback_spinner is None assert io.spinner_running is False + + def test_no_spinner_suppresses_tui_spinner_messages(self): + """The existing spinner option also gates the Textual adapter.""" + from cecli.args import get_parser + from cecli.tui import create_tui_io + + args = get_parser(default_config_files=[], git_root=None).parse_args(["--no-spinner"]) + io, output_queue, _ = create_tui_io(args, EditingMode.EMACS) + + io.start_spinner("Working") + io.stop_spinner() + + assert io.spinner_active is False + assert output_queue.empty() diff --git a/tests/tui/test_motion.py b/tests/tui/test_motion.py new file mode 100644 index 00000000000..8946459542c --- /dev/null +++ b/tests/tui/test_motion.py @@ -0,0 +1,50 @@ +from textual.app import App, ComposeResult + +from cecli.tui.widgets.footer import MainFooter +from cecli.tui.widgets.input_container import InputContainer + + +class FooterApp(App): + def compose(self) -> ComposeResult: + yield MainFooter(id="footer") + + +class PollingInputContainer(InputContainer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.poll_count = 0 + + def _refresh_sub_agents(self): + self.poll_count += 1 + super()._refresh_sub_agents() + + +class InputContainerApp(App): + def compose(self) -> ComposeResult: + yield PollingInputContainer(coder_mode="agent", id="input-container") + + +async def test_reduced_motion_footer_uses_static_activity_indicator(): + app = FooterApp() + app.animation_level = "none" + + async with app.run_test(): + footer = app.query_one(MainFooter) + footer.start_spinner("Working") + + assert footer._spinner_interval is None + assert footer.render().plain.startswith("• Working") + + +async def test_reduced_motion_keeps_subagent_polling_without_alternating_icons(): + app = InputContainerApp() + app.animation_level = "none" + + async with app.run_test() as pilot: + container = app.query_one(PollingInputContainer) + container.show_squares = False + + await pilot.pause(1.1) + + assert container.poll_count >= 1 + assert container.show_squares is False From 8b2552d91071c617dc765274e149e457af12dee7 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 24 Sep 2026 02:20:22 -0500 Subject: [PATCH 02/28] test: cover core linear-mode flows --- cecli/coders/base_coder.py | 3 + tests/basic/test_linear_mode.py | 179 ++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/basic/test_linear_mode.py diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index cc62c69493e..a5c8e3d600d 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2113,6 +2113,7 @@ async def run_one(self, user_message, preproc): if item is not None: self.io.tool_output(f"Processing queued prompt (id: {item['id']})...") + self.io.user_input(item["text"]) await self.run_one(item["text"], preproc) if not await HookIntegration.call_end_hooks(self): @@ -3656,6 +3657,8 @@ async def add_assistant_reply_to_cur_messages(self): if self.partial_response_consolidated: response = self.partial_response_consolidated[0] elif not self.stream: + if not self.partial_response_chunks: + return response = self.partial_response_chunks[0] else: response = litellm.stream_chunk_builder(self.partial_response_chunks) diff --git a/tests/basic/test_linear_mode.py b/tests/basic/test_linear_mode.py new file mode 100644 index 00000000000..91afcf3ff62 --- /dev/null +++ b/tests/basic/test_linear_mode.py @@ -0,0 +1,179 @@ +import asyncio +import hashlib +import inspect +import re +from types import SimpleNamespace + +from cecli.coders import Coder +from cecli.helpers import command_queue, coroutines +from cecli.io import InputOutput +from cecli.llm import litellm + +LINEAR_OUTPUT_VIOLATION = re.compile( + r"\r(?!\n)|\x08|\x1b(?:M|[78])|(?:\x1b\[|\x9b)" + r"(?!(?:[0-?]*[ -/]*m|\?25[hl]))[0-?]*[ -/]*[@-~]" +) + + +def assert_linear_transcript(transcript): + assert not LINEAR_OUTPUT_VIOLATION.search(transcript) + + +def captured_terminal(capsys): + captured = capsys.readouterr() + return captured.out + captured.err + + +def tool_call_response(name, arguments, call_id): + return litellm.ModelResponse( + model="fake", + choices=[ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + ], + }, + } + ], + ) + + +class FakeLLM: + def __init__(self, replies): + self.replies = iter(replies) + + async def send_completion(self, *args, **kwargs): + reply = next(self.replies) + if callable(reply): + reply = reply() + if inspect.isawaitable(reply): + reply = await reply + if isinstance(reply, BaseException): + raise reply + if isinstance(reply, litellm.ModelResponse): + response = reply + else: + response = litellm.ModelResponse( + model="fake", + choices=[{"index": 0, "message": {"role": "assistant", "content": reply}}], + ) + return hashlib.sha1(repr(reply).encode()), response + + +async def make_linear_coder(tmp_path, model, replies, monkeypatch, edit_format="diff"): + monkeypatch.setattr("cecli.coders.base_coder.trim_memory", lambda: None) + monkeypatch.setattr(coroutines, "fire_and_forget", lambda coro: coro.close()) + loop = asyncio.get_running_loop() + + def run_inline(_executor, func, *args): + future = loop.create_future() + try: + future.set_result(func(*args)) + except BaseException as error: + future.set_exception(error) + return future + + monkeypatch.setattr(loop, "run_in_executor", run_inline) + transcript_path = tmp_path / "transcript.md" + io = InputOutput(chat_history_file=transcript_path, pretty=False, yes=True, show_spinner=False) + args = SimpleNamespace(tui=False, fancy_input=True, debug=False) + coder = await Coder.create( + model, + edit_format, + io=io, + args=args, + use_git=False, + stream=False, + linear_output=True, + auto_commits=False, + auto_lint=False, + detect_urls=False, + root=tmp_path, + ) + coder.main_model.send_completion = FakeLLM(replies).send_completion + coder.interrupt_event = asyncio.Event() + + async def interruptible(awaitable, _event): + return await awaitable, False + + coder.coroutines = SimpleNamespace(interruptible=interruptible) + + return coder, transcript_path + + +async def test_queued_input_is_serialized_after_current_turn( + tmp_path, gpt35_model, monkeypatch, capsys +): + started = asyncio.Event() + release = asyncio.Event() + + async def first_reply(): + started.set() + await release.wait() + return "first reply" + + coder, transcript_path = await make_linear_coder( + tmp_path, gpt35_model, [first_reply, "queued reply"], monkeypatch + ) + run_task = asyncio.create_task(coder._run_linear("first prompt")) + await asyncio.wait_for(started.wait(), timeout=5) + command_queue.enqueue_prompt(coder, "second request sentinel") + release.set() + await asyncio.wait_for(run_task, timeout=5) + + transcript = transcript_path.read_text(encoding="utf-8") + assert_linear_transcript(captured_terminal(capsys)) + assert transcript.index("first prompt") < transcript.index("first reply") + assert transcript.index("first reply") < transcript.index("second request sentinel") + assert transcript.index("second request sentinel") < transcript.index("queued reply") + + +async def test_interrupt_returns_to_usable_linear_prompt( + tmp_path, gpt35_model, monkeypatch, capsys +): + coder, transcript_path = await make_linear_coder( + tmp_path, + gpt35_model, + [asyncio.CancelledError(), "recovered reply"], + monkeypatch, + ) + + await asyncio.wait_for(coder._run_linear("interrupted request"), timeout=5) + await asyncio.wait_for(coder._run_linear("request after interrupt"), timeout=5) + + transcript = transcript_path.read_text(encoding="utf-8") + assert_linear_transcript(captured_terminal(capsys)) + assert "^C KeyboardInterrupt" in transcript + assert transcript.index("request after interrupt") < transcript.index("recovered reply") + + +async def test_local_tool_round_trip_is_serialized(tmp_path, gpt35_model, monkeypatch, capsys): + (tmp_path / "visible.txt").write_text("visible") + tool_call = tool_call_response("local--ls", '{"path":"."}', "call_ls") + done = tool_call_response( + "local--Yield", '{"summary":"tool round trip complete"}', "call_yield" + ) + coder, transcript_path = await make_linear_coder( + tmp_path, + gpt35_model, + [tool_call, done], + monkeypatch, + edit_format="agent", + ) + + await asyncio.wait_for(coder._run_linear("list the directory"), timeout=5) + + transcript = transcript_path.read_text(encoding="utf-8") + assert_linear_transcript(captured_terminal(capsys)) + assert "Tool Call: local • ls" in transcript + assert "path: ." in transcript + assert "Listed" in transcript + assert "tool round trip complete" in transcript From bd5ff2448d598cfabd4d832626164e3360a647db Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 24 Sep 2026 02:23:08 -0500 Subject: [PATCH 03/28] test: enforce linear transcript invariants --- cecli/io.py | 2 +- tests/basic/test_linear_mode.py | 63 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/cecli/io.py b/cecli/io.py index a3bf286315f..a9891c085c7 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -571,7 +571,7 @@ def start_spinner(self, text, update_last_text=True, **kwargs): """Start the spinner.""" self.stop_spinner() - if not self.spinner_active: + if not self.spinner_active or self.linear: return if self.prompt_session: diff --git a/tests/basic/test_linear_mode.py b/tests/basic/test_linear_mode.py index 91afcf3ff62..6f0feb19c5f 100644 --- a/tests/basic/test_linear_mode.py +++ b/tests/basic/test_linear_mode.py @@ -4,6 +4,8 @@ import re from types import SimpleNamespace +import pytest + from cecli.coders import Coder from cecli.helpers import command_queue, coroutines from cecli.io import InputOutput @@ -177,3 +179,64 @@ async def test_local_tool_round_trip_is_serialized(tmp_path, gpt35_model, monkey assert "path: ." in transcript assert "Listed" in transcript assert "tool round trip complete" in transcript + + +@pytest.mark.parametrize( + "transcript", + [ + "progress\rrewrite", + "\x1b[1A", + "\x1b[1B", + "\x1b[1C", + "\x1b[1D", + "\x1b[1G", + "\x1b[2;3H", + "\x1b[4f", + "\x1b[2K", + "\x1b[J", + "\x1b[?2J", + "\x1b[?2K", + "\x1bM", + "\x08", + "\x1b7", + "\x1b8", + "\x1b[s", + "\x1b[u", + "\x9b2J", + "\x1b[?1049h", + "\x1b[?1049l", + "\x1b[?25;1049h", + "\x1b[P", + "\x1b[X", + "\x1b[L", + "\x1b[M", + "\x1b[@", + "\x1b[I", + "\x1b[Z", + "\x1b[`", + "\x1b[a", + "\x1b[d", + "\x1b[e", + ], +) +def test_linear_transcript_rejects_rewrites(transcript): + with pytest.raises(AssertionError): + assert_linear_transcript(transcript) + + +def test_linear_transcript_allows_sgr_and_crlf(): + assert_linear_transcript("\x1b[?25l\x1b[31mred\x1b[0m\x1b[?25h\r\n") + + +@pytest.mark.parametrize("prompt_session", [object(), None], ids=["toolbar", "fallback"]) +def test_linear_output_suppresses_dynamic_spinners(prompt_session): + io = InputOutput(pretty=False) + io.linear = True + io.prompt_session = prompt_session + try: + io.start_spinner("Processing...") + assert io.spinner_running is False + assert io.fallback_spinner is None + assert io.get_bottom_toolbar() is None + finally: + io.stop_spinner() From 81720c9e7e3722a05225c4d21d65d72a8a217c85 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 24 Sep 2026 02:24:03 -0500 Subject: [PATCH 04/28] fix: clean up linear agent task lifecycles --- cecli/coders/base_coder.py | 2 +- cecli/helpers/agents/service.py | 10 +- cecli/io.py | 2 +- cecli/main.py | 14 ++ cecli/tools/_yield.py | 3 +- tests/basic/test_linear_mode.py | 249 +++++++++++++++++++++++++++++++ tests/subagents/test_finished.py | 39 ++++- tests/subagents/test_service.py | 12 ++ 8 files changed, 324 insertions(+), 7 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index a5c8e3d600d..942bcd96041 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2357,7 +2357,7 @@ async def summarize_and_update(messages, tag): if self.auto_memory and self.edit_format not in ["subagent"]: from cecli.helpers.memory.utils import invoke_memorizer - asyncio.create_task(invoke_memorizer(self, additional_context=text)) + coroutines.fire_and_forget(invoke_memorizer(self, additional_context=text)) await self._rate_limit_sleep() if done_tokens > self.context_compaction_max_tokens or done_tokens > cur_tokens: diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index b9146726a46..189c740c361 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -1165,9 +1165,13 @@ def foreground_coder(self): def cleanup_all_for_parent(self) -> None: """Clean up all sub-agents when the parent session ends.""" + parent_uuid = self.coder.uuid for uuid in list(self.sub_agents.keys()): self._cleanup_sub_agent(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) - self._instances.pop(self.coder.uuid, None) + self._spawn_locks.pop(parent_uuid, None) + self._conversation_locks.pop(parent_uuid, None) + self._instances.pop(parent_uuid, None) + self._uuid_coder_map.pop(parent_uuid, None) + if type(self)._primary_agent_uuid == parent_uuid: + type(self)._primary_agent_uuid = None diff --git a/cecli/io.py b/cecli/io.py index a9891c085c7..0805060dd65 100644 --- a/cecli/io.py +++ b/cecli/io.py @@ -1383,7 +1383,7 @@ async def _confirm_ask( good = any(valid_response.startswith(res) for valid_response in valid_responses) if good: - if not acknowledge: + if not acknowledge and not self.linear: self.set_confirmation_acknowledgement() self.start_spinner(self.last_spinner_text) break diff --git a/cecli/main.py b/cecli/main.py index b59db284c83..4e688304f9a 100644 --- a/cecli/main.py +++ b/cecli/main.py @@ -1774,6 +1774,20 @@ async def graceful_exit(coder=None, exit_code=0): if hasattr(coder, "_autosave_future"): await coder._autosave_future + from cecli.helpers import coroutines + from cecli.helpers.agents.service import AgentService + + await coroutines.cancel_and_abandon(list(coroutines.background_tasks)) + agent_service = AgentService.get_instance(coder) + agent_tasks = [ + info.generate_task + for info in agent_service.sub_agents.values() + if info.generate_task is not None + ] + await coroutines.cancel_and_abandon(agent_tasks) + agent_service.cleanup_all_for_parent() + await coroutines.cancel_and_abandon(list(coroutines.background_tasks)) + if coder.mcp_manager and coder.mcp_manager.is_connected: await coder.mcp_manager.disconnect_all() # Cleanup old agent directories diff --git a/cecli/tools/_yield.py b/cecli/tools/_yield.py index 38262cb6c72..d458d84cabf 100644 --- a/cecli/tools/_yield.py +++ b/cecli/tools/_yield.py @@ -1,6 +1,7 @@ import asyncio import logging +from cecli.helpers import coroutines from cecli.helpers.threading import ThreadSafeEvent from cecli.tools.utils.base_tool import BaseTool from cecli.tools.utils.helpers import ToolError @@ -242,7 +243,7 @@ async def execute(cls, coder, **kwargs): if agent_service.get_agent_name(coder) != "memorizer": from cecli.helpers.memory.utils import invoke_memorizer - asyncio.create_task( + coroutines.fire_and_forget( invoke_memorizer(coder, additional_context=f"Yield summary: {summary}") ) parent_uuid = coder.parent_uuid diff --git a/tests/basic/test_linear_mode.py b/tests/basic/test_linear_mode.py index 6f0feb19c5f..59fe8e8e915 100644 --- a/tests/basic/test_linear_mode.py +++ b/tests/basic/test_linear_mode.py @@ -3,6 +3,7 @@ import inspect import re from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -10,6 +11,7 @@ from cecli.helpers import command_queue, coroutines from cecli.io import InputOutput from cecli.llm import litellm +from cecli.mcp import McpServer LINEAR_OUTPUT_VIOLATION = re.compile( r"\r(?!\n)|\x08|\x1b(?:M|[78])|(?:\x1b\[|\x9b)" @@ -181,6 +183,253 @@ async def test_local_tool_round_trip_is_serialized(tmp_path, gpt35_model, monkey assert "tool round trip complete" in transcript +async def test_mcp_tool_round_trip_returns_to_conversation( + tmp_path, gpt35_model, monkeypatch, capsys +): + tool_call = tool_call_response("test-mcp--echo", '{"message":"hello"}', "call_echo") + done = tool_call_response("local--Yield", '{"summary":"mcp round trip complete"}', "call_yield") + coder, transcript_path = await make_linear_coder( + tmp_path, gpt35_model, [tool_call, done], monkeypatch, edit_format="agent" + ) + session = SimpleNamespace( + call_tool=AsyncMock( + return_value=SimpleNamespace(content=[SimpleNamespace(text="MCP result sentinel")]) + ) + ) + server = McpServer({"name": "Test-MCP"}) + server.connect = AsyncMock(return_value=session) + coder.mcp_manager._servers.append(server) + coder.mcp_manager._connected_servers.add(server) + coder.mcp_manager._server_tools[server.name] = [ + { + "type": "function", + "function": { + "name": "echo", + "description": "Echo a message", + "parameters": { + "type": "object", + "properties": {"message": {"type": "string"}}, + }, + }, + } + ] + + await asyncio.wait_for(coder._run_linear("call the MCP server"), timeout=5) + + transcript = transcript_path.read_text(encoding="utf-8") + tool_results = [message for message in coder.cur_messages if message.get("tool_call_id")] + assert_linear_transcript(captured_terminal(capsys)) + session.call_tool.assert_awaited_once_with(name="echo", arguments={"message": "hello"}) + assert [message["tool_call_id"] for message in tool_results] == ["call_echo", "call_yield"] + assert "MCP result sentinel" in tool_results[0]["content"] + assert 'Arguments: {"message":"hello"}' in transcript + assert transcript.index("Tool Call: test-mcp • echo") < transcript.index( + "mcp round trip complete" + ) + + +async def test_confirmation_rejection_returns_to_usable_prompt( + tmp_path, gpt35_model, monkeypatch, capsys +): + command_call = tool_call_response( + "local--Command", + '{"command":"printf confirmation-sentinel"}', + "call_command", + ) + + coder, transcript_path = await make_linear_coder( + tmp_path, + gpt35_model, + [ + command_call, + tool_call_response( + "local--Yield", '{"summary":"first turn complete"}', "call_first_yield" + ), + tool_call_response( + "local--Yield", '{"summary":"second turn complete"}', "call_second_yield" + ), + ], + monkeypatch, + edit_format="agent", + ) + prompts = [] + coder.io.prompt_session = None + monkeypatch.setattr("builtins.input", lambda prompt: prompts.append(prompt) or "n") + + await asyncio.wait_for(coder._run_linear("request requiring confirmation"), timeout=5) + await asyncio.wait_for(coder._run_linear("request after confirmation"), timeout=5) + + transcript = transcript_path.read_text(encoding="utf-8") + command_result = next( + message for message in coder.cur_messages if message.get("tool_call_id") == "call_command" + ) + assert_linear_transcript(captured_terminal(capsys)) + assert coder.io.get_confirmation_acknowledgement() is False + assert len(prompts) == 1 + assert prompts[0].startswith("Allow execution of this command?") + assert "Command execution skipped by user." in command_result["content"] + assert transcript.index("first turn complete") < transcript.index("request after confirmation") + assert transcript.index("request after confirmation") < transcript.index("second turn complete") + + +async def test_delegate_activity_is_serialized_before_parent_continues( + tmp_path, gpt35_model, monkeypatch, capsys +): + coder, transcript_path = await make_linear_coder( + tmp_path, + gpt35_model, + [ + tool_call_response( + "local--Delegate", + '{"delegations":[{"name":"worker","prompt":"child task sentinel",' + '"async":false}]}', + "call_delegate", + ), + tool_call_response( + "local--Yield", '{"summary":"parent handled child summary"}', "call_yield" + ), + tool_call_response( + "local--Yield", '{"summary":"parent prompt still works"}', "call_followup" + ), + ], + monkeypatch, + edit_format="agent", + ) + + child_started = asyncio.Event() + release_child = asyncio.Event() + + async def invoke(name, prompt, **kwargs): + child_started.set() + await release_child.wait() + coder.io.tool_output("child output sentinel") + return "child summary sentinel" + + agent_service = SimpleNamespace( + invoke=AsyncMock(side_effect=invoke), + get_children=lambda _coder: [], + get_parent=lambda _coder: None, + reap_all_finished_agents=AsyncMock(), + ) + monkeypatch.setattr( + "cecli.helpers.agents.service.AgentService.get_instance", + lambda _coder=None: agent_service, + ) + + delegate_task = asyncio.create_task(coder._run_linear("delegate request")) + await asyncio.wait_for(child_started.wait(), timeout=5) + assert not delegate_task.done() + assert "parent handled child summary" not in transcript_path.read_text(encoding="utf-8") + release_child.set() + await asyncio.wait_for(delegate_task, timeout=5) + await asyncio.wait_for(coder._run_linear("parent follow-up sentinel"), timeout=5) + + transcript = transcript_path.read_text(encoding="utf-8") + delegate_result = next( + message for message in coder.cur_messages if message.get("tool_call_id") == "call_delegate" + ) + assert_linear_transcript(captured_terminal(capsys)) + assert "child summary sentinel" in delegate_result["content"] + assert transcript.index("child task sentinel") < transcript.index("child output sentinel") + assert transcript.index("child output sentinel") < transcript.index( + "parent handled child summary" + ) + assert transcript.index("parent handled child summary") < transcript.index( + "parent follow-up sentinel" + ) + assert transcript.index("parent follow-up sentinel") < transcript.index( + "parent prompt still works" + ) + + +async def test_compaction_commands_and_cleanup_under_linear_mode( + tmp_path, gpt35_model, monkeypatch, capsys +): + from cecli.helpers.agents.service import AgentService + from cecli.main import graceful_exit + + real_fire_and_forget = coroutines.fire_and_forget + coder, transcript_path = await make_linear_coder( + tmp_path, + gpt35_model, + [ + tool_call_response( + "local--Yield", '{"summary":"context before compaction"}', "call_yield" + ) + ], + monkeypatch, + edit_format="agent", + ) + await asyncio.wait_for(coder._run_linear("seed compactable context"), timeout=5) + monkeypatch.setattr(coroutines, "fire_and_forget", real_fire_and_forget) + + memorizer_started = asyncio.Event() + memorizer_cancelled = asyncio.Event() + + async def blocked_memorizer(*args, **kwargs): + memorizer_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + memorizer_cancelled.set() + raise + + monkeypatch.setattr("cecli.helpers.memory.utils.invoke_memorizer", blocked_memorizer) + + coder.enable_context_compaction = True + coder.context_compaction_max_tokens = 1 + coder.context_compaction_summary_tokens = 32 + coder.summarizer.count_tokens = lambda messages: 100 if messages else 0 + coder.summarizer.summarize_all_as_text = AsyncMock(return_value="compacted sentinel") + + await asyncio.wait_for(coder._run_linear("/compact retain the sentinel"), timeout=5) + await asyncio.wait_for(coder._run_linear("/help"), timeout=5) + await asyncio.wait_for(coder._run_linear("/tokens"), timeout=5) + await asyncio.wait_for(memorizer_started.wait(), timeout=5) + + child_started = asyncio.Event() + child_cancelled = asyncio.Event() + child_finalizer_cancelled = asyncio.Event() + + async def child_finalizer(): + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + child_finalizer_cancelled.set() + raise + + async def active_child(): + child_started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + child_cancelled.set() + raise + finally: + coroutines.fire_and_forget(child_finalizer()) + + child_task = asyncio.create_task(active_child()) + await asyncio.wait_for(child_started.wait(), timeout=5) + agent_service = AgentService.get_instance(coder) + agent_service.sub_agents["exit-child"] = SimpleNamespace( + coder=SimpleNamespace(uuid="exit-child"), generate_task=child_task + ) + agent_service._sub_agent_order.append("exit-child") + + await graceful_exit(coder) + await asyncio.sleep(0) + + transcript = transcript_path.read_text(encoding="utf-8") + assert_linear_transcript(captured_terminal(capsys)) + assert "Forcing compaction of chat history" in transcript + assert "Use `/help `" in transcript + assert "Token report generated" in transcript + assert memorizer_cancelled.is_set() + assert child_cancelled.is_set() + assert child_finalizer_cancelled.is_set() + assert not coroutines.background_tasks + + @pytest.mark.parametrize( "transcript", [ diff --git a/tests/subagents/test_finished.py b/tests/subagents/test_finished.py index 7c6a607bbc2..37f9e185729 100644 --- a/tests/subagents/test_finished.py +++ b/tests/subagents/test_finished.py @@ -2,7 +2,8 @@ Tests for cecli/tools/finished.py — Finished tool sub-agent integration. """ -from unittest.mock import MagicMock, patch +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -113,6 +114,42 @@ async def test_returns_summary_in_response(self): result = await Tool.execute(mock_coder, summary="completed successfully") assert "Summary: completed successfully" in str(result) + async def test_memorizer_is_registered_as_background_work(self): + """Yield summaries must use the tracked background-task helper.""" + from cecli.helpers import coroutines + from cecli.helpers.agents.service import AgentService + from cecli.tools._yield import Tool + + mock_coder = MagicMock( + uuid="test-uuid", + parent_uuid="", + files_edited_by_tools=set(), + auto_memory=True, + turn_count=5, + ) + service = MagicMock() + service.get_children.return_value = [] + service.get_parent.return_value = None + service.reap_all_finished_agents = AsyncMock() + service.get_agent_name.return_value = "primary" + started = asyncio.Event() + + async def blocked_memorizer(*args, **kwargs): + started.set() + await asyncio.Event().wait() + + try: + with ( + patch.object(AgentService, "get_instance", return_value=service), + patch("cecli.helpers.memory.utils.invoke_memorizer", blocked_memorizer), + ): + await Tool.execute(mock_coder, summary="remember this") + await asyncio.wait_for(started.wait(), timeout=1) + + assert any(not task.done() for task in coroutines.background_tasks) + finally: + await coroutines.cancel_and_abandon(list(coroutines.background_tasks)) + @pytest.mark.asyncio async def test_coder_is_none_returns_error(self): """When coder is None, returns error string.""" diff --git a/tests/subagents/test_service.py b/tests/subagents/test_service.py index 42947c04b8b..4d1c4ca5e6b 100644 --- a/tests/subagents/test_service.py +++ b/tests/subagents/test_service.py @@ -703,6 +703,18 @@ def test_empty_sub_agents(self, service): assert service.coder.uuid not in AgentService._instances + def test_cleanup_allows_a_new_primary_session(self, service): + """A later in-process session must not retain the old primary key.""" + AgentService._primary_agent_uuid = service.coder.uuid + AgentService._instances[service.coder.uuid] = service + AgentService._uuid_coder_map[service.coder.uuid] = service.coder + + service.cleanup_all_for_parent() + next_coder = MagicMock(uuid="next-parent", parent_uuid="", max_sub_agents=3) + next_coder.io = MagicMock() + + assert AgentService.get_instance(next_coder).coder is next_coder + # ================================================================== # # keep_files metadata From 955c9e5a8a7dfd466c0a1b412b2c6cfdea054e2a Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Thu, 24 Sep 2026 02:24:29 -0500 Subject: [PATCH 05/28] docs: describe supported linear terminal mode --- cecli/website/docs/config/tui.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/cecli/website/docs/config/tui.md b/cecli/website/docs/config/tui.md index c444dd14bbf..a7b70a71191 100644 --- a/cecli/website/docs/config/tui.md +++ b/cecli/website/docs/config/tui.md @@ -15,6 +15,32 @@ The TUI is the default in recent versions of Cecli and on older versions it can cecli ... --tui ``` +## Linear terminal mode + +For an interactive session without the full-screen TUI, use this canonical +profile in `~/.cecli/conf.yml` or `.cecli.conf.yml`: + +```yaml +tui: false +linear-output: true +fancy-input: true +stream: true +``` + +This keeps the sophisticated `prompt_toolkit` input editor, including history +and completion, while making output unsophisticated and sequential. Streaming +responses remain enabled, but Cecli does not redraw earlier application output. +That linear application-output stream contains no cursor-motion or erasure +sequences, and a carriage return appears only immediately before a line feed as +part of a CRLF line ending. The input editor may still use terminal controls +while editing the current prompt. + +This conservative output profile is useful over SSH, in limited terminals, and +when preserving output in logs or passing it through scripts and pipes. Cecli +automatically disables fancy input when it detects a dumb terminal. For other +terminals that cannot reliably support `prompt_toolkit`, set +`fancy-input: false`. + ## Configuration The TUI can be configured directly in the relevant config.json file or with JSON in the command line arguments: From 5c426c0b2242db3a10658678def76f1f1a0ea4a0 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Sat, 26 Sep 2026 06:57:30 -0500 Subject: [PATCH 06/28] docs: document non-TUI input and one-shot --message contract Record the input contract that non-TUI mode now guarantees: - --message / --message-file are one-shot; there is no follow-up answer path in that mode, so a question-ending instruction cannot be answered. Multi-turn runs use interactive mode instead. - In interactive mode input comes from the TUI box (tui: true), from stdin with a re-prompt per turn (tui: false), or from a queue push ({"text": ...} and {"confirmed": ...}) used by the WebSocket/ACP server and the AgentService wake path. - Both the TUI and non-TUI input paths consume the same per-coder queue, so a headless or server-driven session can answer prompts with no terminal. Documentation only; no code change. Planned to merge after the non-TUI queued-input fix so the queue contract it describes is accurate. --- cecli/website/docs/config/tui.md | 15 +++++++++++++++ cecli/website/docs/scripting.md | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/cecli/website/docs/config/tui.md b/cecli/website/docs/config/tui.md index c444dd14bbf..25c38b8b38f 100644 --- a/cecli/website/docs/config/tui.md +++ b/cecli/website/docs/config/tui.md @@ -15,6 +15,21 @@ The TUI is the default in recent versions of Cecli and on older versions it can cecli ... --tui ``` +## Non-TUI Mode + +Pass `--no-tui` (or set `tui: false`) to run the plain terminal interface +instead of the full-screen TUI. In this mode: + +- Input is read from stdin and re-prompted after each turn. +- Confirmation prompts are read from the terminal. +- Input and confirmations pushed by an external driver are also honored: the + [WebSocket/ACP server](api.html) and the AgentService wake path push onto the + coder's input queue, so a headless or server-driven session can answer prompts + with no terminal attached. + +Both modes consume the same per-coder input queue, so prompt handling is +consistent whether or not the TUI is running. + ## Configuration The TUI can be configured directly in the relevant config.json file or with JSON in the command line arguments: diff --git a/cecli/website/docs/scripting.md b/cecli/website/docs/scripting.md index f01610521fd..7451a18bce4 100644 --- a/cecli/website/docs/scripting.md +++ b/cecli/website/docs/scripting.md @@ -24,6 +24,12 @@ for FILE in *.py ; do done ``` +> **Note:** `--message` (`-m`) and `--message-file` (`-f`) are **one-shot**. +> Cecli sends the message, lets the model act on it, then exits. There is no +> follow-up prompt in this mode, so an instruction that ends by asking you a +> question has nowhere to collect your answer. Pass `--yes` for fully +> non-interactive runs, or use one of the multi-turn routes below. + Use `cecli --help` to see all the [command line options](config/options.html), but these are useful for scripting: ``` @@ -50,6 +56,23 @@ Use `cecli --help` to see all the [command line options](config/options.html), b [env var: CECLI_COMMIT] ``` +### Multi-turn input + +Run cecli without `--message` and it stays up for a back-and-forth session. Where +your input comes from depends on the mode: + +- **TUI** (`tui: true`, the default): type into the chat input box. +- **Non-TUI** (`--no-tui`, or `tui: false`): input is read from stdin and + re-prompted after every turn. Questions and confirmation prompts from the + agent are answered at the terminal prompt. +- **Programmatic**: an external driver pushes `{"text": ...}` (and + `{"confirmed": ...}` for yes/no confirmations) onto the coder's input queue. + The [WebSocket/ACP server](config/api.html) and the AgentService wake path use + this route. + +Both input paths consume the same per-coder queue, so a headless or +server-driven session can answer prompts with no terminal attached. + ## Python From e0d50ed90e4dd5ca82c5d0020788db2ad82dd0b6 Mon Sep 17 00:00:00 2001 From: Chris Nestrud Date: Sat, 26 Sep 2026 06:57:23 -0500 Subject: [PATCH 07/28] fix: deliver queued input and confirmations in non-TUI IOProxy Root cause: IOProxy.get_input only honored a coder's input queue in TUI mode. In non-TUI mode it delegated straight to the base InputOutput terminal prompt, so answers pushed onto a coder's queue by the WebSocket server, the ACP adapter, or AgentService.wake_primary were silently dropped. confirm_ask had the same gap, so queued confirmations were dropped too. Fix: in non-TUI mode, sweep this coder's queue first. If no payload is waiting and a cancellable prompt session is present, race the terminal read against the per-coder queue wake so a push is delivered even while the prompt is open. The global InterruptibleInput/input() fallback stays a plain blocking read (marked with a ponytail: note) because it cannot be cancelled; a push arriving during that read is delivered on the next get_input() cycle. Shape rationale: the wake event in helpers/queues is global, so the race loop re-checks this coder's own queue after every wake rather than trusting a single signal. A payload for a different reader is put back so its own waiter can consume it. Confirmations reuse the same path and share the TUI payload mapping (never/tweak/all/skip/bool) so both modes agree. Tests cover queued text, a push delivered while the prompt is open, a confirmation left for confirm_ask by get_input, and queued true/false confirmations. --- cecli/helpers/io_proxy.py | 137 +++++++++++++++++++++++++++++-- tests/subagents/test_io_proxy.py | 133 ++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 7 deletions(-) diff --git a/cecli/helpers/io_proxy.py b/cecli/helpers/io_proxy.py index 0dfb87154b4..85787c361cb 100644 --- a/cecli/helpers/io_proxy.py +++ b/cecli/helpers/io_proxy.py @@ -71,6 +71,70 @@ def __init__(self, target: T, coder: Any) -> None: def unwrap(cls, io): return io._target if isinstance(io, cls) else io + # ------------------------------------------------------------------ # + # Per-coder queue helpers (non-TUI mode) + # ------------------------------------------------------------------ # + + def _take_queued(self, key: str): + """Pop this coder's next queue payload containing *key*, else None. + + A payload meant for a different reader — for example a confirmation + seen while waiting for text — is put back so its own waiter can still + consume it. + """ + try: + payload = self._input_queue.get_nowait() + except _queue.Empty: + return None + if isinstance(payload, dict) and key in payload: + return payload + self._input_queue.put(payload) + return None + + async def _terminal_or_queued(self, terminal, take): + """Await *terminal*, returning early when *take()* yields a payload. + + A queued payload is delivered even while the terminal prompt is open: + the read is raced against the per-coder queue wake. That wake event is + global (any coder's push sets it), so re-check this coder's own queue + before racing again. + """ + queued = take() + if queued is not None: + return queued + + if getattr(self, "prompt_session", None) is None: + # ponytail: the InterruptibleInput/input() fallback reads on a + # worker thread and cannot be cancelled, so a push arriving + # mid-read lands on the next get_input() cycle instead of + # preempting this read. Make the fallback cancellable if it ever + # needs instant delivery. + return await terminal() + + from cecli.helpers import queues + + terminal_task = asyncio.create_task(terminal()) + try: + while True: + wake_task = asyncio.create_task(queues.wait_for_input()) + try: + await asyncio.wait( + {terminal_task, wake_task}, return_when=asyncio.FIRST_COMPLETED + ) + finally: + if not wake_task.done(): + wake_task.cancel() + if terminal_task.done(): + # Surfaces the terminal read's own EOFError/SystemExit. + return terminal_task.result() + queued = take() + if queued is not None: + terminal_task.cancel() + return queued + except BaseException: + terminal_task.cancel() + raise + # ------------------------------------------------------------------ # # Intercepted methods — inject coder_uuid into each call # ------------------------------------------------------------------ # @@ -140,8 +204,10 @@ async def get_input(self, *args, **kwargs): proxy's coder, the input is for a sub-agent — route it via AgentService by calling generate() on the sub-agent, then loop. - In non-TUI mode, delegates to the base InputOutput and wraps the - plain-string result as ``(user_input, None)``. + In non-TUI mode, delivers a queue-pushed answer (from the WebSocket + server, ACP, or AgentService) when one is waiting, otherwise reads the + base InputOutput terminal prompt. The terminal read is raced against + the queue wake so a push is delivered even while the prompt is open. Returns: tuple[str, str | None]: (user_input, coder_uuid). @@ -174,19 +240,76 @@ async def get_input(self, *args, **kwargs): return user_input, coder_uuid return (result, None) - # Non-TUI mode: delegate to base InputOutput - result = await self._target.get_input(*args, **kwargs) + # Non-TUI mode: honor a queue-pushed answer, else read the terminal. + def take_text(): + payload = self._take_queued("text") + if payload is None: + return None + text = payload["text"] + self.user_input(text) + return text, payload.get("coder_uuid", self._coder_uuid) + + result = await self._terminal_or_queued( + lambda: self._target.get_input(*args, **kwargs), take_text + ) if isinstance(result, tuple) and len(result) == 2: return result return (result, None) async def confirm_ask(self, *args, **kwargs): - """Forward confirm_ask — per-coder queue iteration is handled by - TextualInputOutput which now iterates all per-coder queues.""" + """Confirm with the TUI queues or the terminal. + + TUI mode: TextualInputOutput.confirm_ask iterates all per-coder queues. + Non-TUI mode: honor a ``{"confirmed": ...}`` payload pushed to this + coder's queue (WebSocket server, ACP), otherwise ask on the terminal. + """ if "coder_uuid" not in kwargs: kwargs["coder_uuid"] = self._coder_uuid - return await self._target.confirm_ask(*args, **kwargs) + + if hasattr(self._target, "output_queue"): + return await self._target.confirm_ask(*args, **kwargs) + + question = args[0] if args else kwargs.get("question") + question_id = (question, kwargs.get("subject")) + group = kwargs.get("group") + group_response = kwargs.get("group_response") + + def take_confirmation(): + payload = self._take_queued("confirmed") + if payload is None: + return None + return self._apply_confirmation( + payload["confirmed"], question_id, group, group_response + ) + + return await self._terminal_or_queued( + lambda: self._target.confirm_ask(*args, **kwargs), take_confirmation + ) + + def _apply_confirmation(self, response, question_id, group, group_response): + """Map a queued confirmation payload to a result. + + Mirrors TextualInputOutput.confirm_ask's queue handling. + """ + if response == "never": + self.never_prompts.add(question_id) + return False + if response == "tweak": + return "tweak" + if response == "all": + if group: + group.preference = "all" + if group_response: + self.group_responses[group_response] = True + return True + if response == "skip": + if group: + group.preference = "skip" + if group_response: + self.group_responses[group_response] = False + return False + return bool(response) async def recreate_input(self, future=None): """Per-coder recreate_input — each coder gets its own input task. diff --git a/tests/subagents/test_io_proxy.py b/tests/subagents/test_io_proxy.py index 0b49b1d10e4..f4c6ad67d89 100644 --- a/tests/subagents/test_io_proxy.py +++ b/tests/subagents/test_io_proxy.py @@ -2,6 +2,7 @@ Tests for cecli/helpers/io_proxy.py — IOProxy. """ +import asyncio from unittest.mock import AsyncMock, MagicMock import pytest @@ -185,3 +186,135 @@ async def test_setattr_forwards_to_target(self): proxy.some_attr = "value" assert target.some_attr == "value" + + +class _NonTuiTarget: + """Minimal non-TUI InputOutput stand-in: no ``output_queue`` attribute. + + IOProxy selects the TUI path with ``hasattr(target, "output_queue")``, and a + bare MagicMock answers ``hasattr`` for every name, so a plain object is the + only way to exercise the non-TUI branch. + """ + + def __init__(self): + self.prompt_session = None + self.get_input = AsyncMock(return_value="terminal text") + self.confirm_ask = AsyncMock(return_value=True) + self.never_prompts = set() + self.group_responses = {} + self.user_inputs = [] + + def user_input(self, inp, log_only=True): + self.user_inputs.append(inp) + + +class TestIOProxyNonTuiQueue: + """Non-TUI IOProxy must consume queue-pushed input and confirmations.""" + + @pytest.mark.asyncio + async def test_get_input_returns_queued_text(self): + from cecli.helpers import queues + from cecli.helpers.io_proxy import IOProxy + + coder_uuid = "queued-text-uuid" + target = _NonTuiTarget() + coder = MagicMock() + coder.uuid = coder_uuid + proxy = IOProxy(target, coder) + try: + assert queues.push_coder_input( + coder_uuid, {"text": "queued answer", "coder_uuid": coder_uuid} + ) + result = await proxy.get_input(None, [], [], []) + + assert result == ("queued answer", coder_uuid) + target.get_input.assert_not_called() + assert target.user_inputs == ["queued answer"] + finally: + queues.unregister_coder_queue(coder_uuid) + + @pytest.mark.asyncio + async def test_get_input_delivers_push_while_prompt_open(self): + from cecli.helpers import queues + from cecli.helpers.io_proxy import IOProxy + + coder_uuid = "queued-race-uuid" + started = asyncio.Event() + + async def blocking_terminal(*args, **kwargs): + started.set() + await asyncio.sleep(30) + return "terminal text" + + target = _NonTuiTarget() + target.prompt_session = object() # cancellable prompt_async path + target.get_input = blocking_terminal + coder = MagicMock() + coder.uuid = coder_uuid + proxy = IOProxy(target, coder) + task = asyncio.create_task(proxy.get_input(None, [], [], [])) + try: + await started.wait() + # The queue waiter clears its wake event when it binds to the loop, + # so push until delivery lands instead of betting on callback order. + for _ in range(100): + queues.push_coder_input( + coder_uuid, {"text": "pushed answer", "coder_uuid": coder_uuid} + ) + try: + result = await asyncio.wait_for(asyncio.shield(task), timeout=0.05) + break + except asyncio.TimeoutError: + continue + else: + raise AssertionError("queued input was never delivered") + + assert result == ("pushed answer", coder_uuid) + finally: + task.cancel() + queues.unregister_coder_queue(coder_uuid) + + @pytest.mark.asyncio + async def test_get_input_leaves_confirmations_for_confirm_ask(self): + from cecli.helpers import queues + from cecli.helpers.io_proxy import IOProxy + + coder_uuid = "queued-mixed-uuid" + target = _NonTuiTarget() + coder = MagicMock() + coder.uuid = coder_uuid + proxy = IOProxy(target, coder) + try: + assert queues.push_coder_input( + coder_uuid, {"confirmed": True, "coder_uuid": coder_uuid} + ) + # get_input must not swallow a confirmation it cannot use. + assert await proxy.get_input(None, [], [], []) == ("terminal text", None) + assert await proxy.confirm_ask("Proceed?") is True + finally: + queues.unregister_coder_queue(coder_uuid) + + @pytest.mark.asyncio + async def test_confirm_ask_returns_queued_confirmation(self): + from cecli.helpers import queues + from cecli.helpers.io_proxy import IOProxy + + coder_uuid = "queued-confirm-uuid" + target = _NonTuiTarget() + coder = MagicMock() + coder.uuid = coder_uuid + proxy = IOProxy(target, coder) + try: + assert queues.push_coder_input( + coder_uuid, {"confirmed": True, "coder_uuid": coder_uuid} + ) + assert await proxy.confirm_ask("Proceed?") is True + + assert queues.push_coder_input( + coder_uuid, {"confirmed": False, "coder_uuid": coder_uuid} + ) + assert await proxy.confirm_ask("Proceed?") is False + + target.confirm_ask.assert_not_called() + finally: + queues.unregister_coder_queue(coder_uuid) From 39657f380b1d93c355d67d78110d83bf5e02e4f8 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Wed, 28 Oct 2026 21:52:24 -0400 Subject: [PATCH 08/28] 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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"} From 228c0f075ad64e201117c0ac690a540ecf825bea Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 09:13:04 -0400 Subject: [PATCH 21/28] Allow non-write tools to run automatically saving tool approvals for edits --- cecli/coders/base_coder.py | 35 ++++++++++++++++++++++++++++++++--- tests/basic/test_coder.py | 24 ++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index a8d752c4a19..840c6fd3995 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -3532,9 +3532,13 @@ async def process_tool_calls(self, tool_call_response): if tool_groups: self._print_tool_call_info(server_tool_calls=tool_groups) - # 4. Ask for user confirmation - if not await self.io.confirm_ask("Run tools?", group_response="Run MCP Tools"): - return False + # 4. Local read-only tool calls are auto-approved; write tools and + # external server tools still ask before running. + if self._tool_calls_need_confirmation(tool_groups): + if not await self.io.confirm_ask( + "Approve tool calls?", group_response="Approve Tool Calls" + ): + return False # 5. Execute tools self.interrupt_event.clear() @@ -3568,6 +3572,31 @@ async def process_tool_calls(self, tool_call_response): return bool(tool_responses) + def _tool_calls_need_confirmation(self, tool_groups): + """Return True when a tool-call batch still requires explicit approval. + + Local tools outside ``write_tools`` only read state, so they run without + prompting. Local write tools and external MCP server tools keep the + existing approval prompt. + """ + write_tools = getattr(self, "write_tools", None) + + for server, tool_calls in tool_groups.items(): + if not isinstance(server, LocalServer): + return True + + if not write_tools: + continue + + for tool_call in tool_calls: + tool_name = responses.unprefix_tool_name(nested.getter(tool_call, "function.name"))[ + 1 + ] + if tool_name.lower() in write_tools: + return True + + return False + def _print_tool_call_info(self, server_tool_calls): """Print information about an MCP tool call.""" # self.io.tool_output("Preparing to run MCP tools", bold=False) diff --git a/tests/basic/test_coder.py b/tests/basic/test_coder.py index 5a815c6a401..e3d34ea9449 100644 --- a/tests/basic/test_coder.py +++ b/tests/basic/test_coder.py @@ -15,7 +15,7 @@ from cecli.helpers.conversation import ConversationService from cecli.helpers.conversation.tags import MessageTag from cecli.io import InputOutput -from cecli.mcp import McpServerManager +from cecli.mcp import LocalServer, McpServerManager from cecli.models import Model from cecli.repo import GitRepo, GitRepoProxy from cecli.sendchat import sanity_check_messages @@ -1691,12 +1691,32 @@ async def test_process_tool_calls_user_rejects(self): # Verify that confirm_ask was called io.confirm_ask.assert_called_once_with( - "Run tools?", group_response="Run MCP Tools", coder_uuid=ANY + "Approve tool calls?", group_response="Approve Tool Calls", coder_uuid=ANY ) # Verify that no messages were added assert len(coder.cur_messages) == 0 + async def test_tool_calls_need_confirmation_skips_local_read_tools(self): + """Read-only local tools run without approval; writes and external tools still ask.""" + with GitTemporaryDirectory(): + io = InputOutput(yes=False) + coder = await Coder.create(self.GPT35, "diff", io=io) + coder.write_tools = {"editfile"} + + local_server = LocalServer({"name": "Local"}) + external_server = MagicMock() + + read_call = MagicMock() + read_call.function.name = "readfile" + + write_call = MagicMock() + write_call.function.name = "editfile" + + assert not coder._tool_calls_need_confirmation({local_server: [read_call]}) + assert coder._tool_calls_need_confirmation({local_server: [write_call]}) + assert coder._tool_calls_need_confirmation({external_server: [read_call]}) + @patch("cecli.coders.base_coder.Coder.call_mcp_tool_from_session", new_callable=AsyncMock) async def test_execute_tool_calls(self, mock_call_tool): """Test that _execute_tool_calls executes tool calls correctly.""" From 9a956c6bd6c48e5aa87a4b9e8b0467c89e590bb3 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 09:16:56 -0400 Subject: [PATCH 22/28] Auto-save on by default --- cecli/args.py | 4 ++-- cecli/website/docs/usage/sessions.md | 2 +- tests/basic/test_session_args.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cecli/args.py b/cecli/args.py index 4a86283ac69..9953a734dae 100644 --- a/cecli/args.py +++ b/cecli/args.py @@ -466,10 +466,10 @@ def get_parser(default_config_files, git_root): group.add_argument( "--auto-save", action=argparse.BooleanOptionalAction, - default=False, + default=True, help=( "Enable/disable automatic saving of sessions as --auto-save-session-name (default:" - " False)" + " True)" ), ) group.add_argument( diff --git a/cecli/website/docs/usage/sessions.md b/cecli/website/docs/usage/sessions.md index 2cc5c494580..ba783f0791a 100644 --- a/cecli/website/docs/usage/sessions.md +++ b/cecli/website/docs/usage/sessions.md @@ -49,7 +49,7 @@ auto-save: true 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. +`--auto-save` is enabled by default, so cecli will automatically save your session as 'auto-save' when you exit; use `--no-auto-save` to disable it. When `--auto-load` is enabled, cecli will automatically load the 'auto-save' session on startup if it exists. ### `/sessions load ` Load a previously saved session by name or file path. diff --git a/tests/basic/test_session_args.py b/tests/basic/test_session_args.py index 7576ee5c439..f620671b1d2 100644 --- a/tests/basic/test_session_args.py +++ b/tests/basic/test_session_args.py @@ -8,7 +8,7 @@ def test_session_encrypt_defaults_off(): args = parser.parse_args([]) assert args.session_encrypt is False assert args.session_key_file is None - assert args.auto_save is False + assert args.auto_save is True assert args.auto_load is False assert args.auto_save_session_name == "auto-save" From 445f4ca2b406a1f862013692a55da1fabdce1340 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sat, 31 Oct 2026 19:01:11 -0400 Subject: [PATCH 23/28] Add an /open panel triggerable with alt+o to see the status of background commands --- cecli/commands/__init__.py | 3 + cecli/commands/open.py | 41 +++ cecli/helpers/background_commands.py | 1 + cecli/tui/app.py | 145 +++++++++ cecli/tui/styles.tcss | 80 +++++ cecli/tui/widgets/__init__.py | 2 + .../tui/widgets/background_commands_panel.py | 197 ++++++++++++ cecli/website/docs/config/tui.md | 8 + tests/tui/test_background_commands_panel.py | 301 ++++++++++++++++++ 9 files changed, 778 insertions(+) create mode 100644 cecli/commands/open.py create mode 100644 cecli/tui/widgets/background_commands_panel.py create mode 100644 tests/tui/test_background_commands_panel.py diff --git a/cecli/commands/__init__.py b/cecli/commands/__init__.py index 191f6f8da49..ebc7aa11823 100644 --- a/cecli/commands/__init__.py +++ b/cecli/commands/__init__.py @@ -47,6 +47,7 @@ from .model import ModelCommand from .models import ModelsCommand from .multiline_mode import MultilineModeCommand +from .open import OpenCommand from .paste import PasteCommand from .queue import QueueCommand from .quit import QuitCommand @@ -130,6 +131,7 @@ CommandRegistry.register(ModelCommand) CommandRegistry.register(ModelsCommand) CommandRegistry.register(MultilineModeCommand) +CommandRegistry.register(OpenCommand) CommandRegistry.register(PasteCommand) CommandRegistry.register(QueueCommand) CommandRegistry.register(QuitCommand) @@ -210,6 +212,7 @@ "ModelCommand", "ModelsCommand", "MultilineModeCommand", + "OpenCommand", "parse_quoted_filenames", "PasteCommand", "QueueCommand", diff --git a/cecli/commands/open.py b/cecli/commands/open.py new file mode 100644 index 00000000000..d13da020c3b --- /dev/null +++ b/cecli/commands/open.py @@ -0,0 +1,41 @@ +"""/open — open a floating TUI panel. + +Subcommands: commands (live background shell task manager). +""" + +from typing import List + +from cecli.commands.utils.base_command import BaseCommand + + +class OpenCommand(BaseCommand): + NORM_NAME = "open" + DESCRIPTION = "Open a floating panel" + + SUBCOMMANDS = ("commands",) + SUBCOMMAND_USAGE = { + "commands": "/open commands", + } + + @classmethod + async def execute(cls, io, coder, args, **kwargs): + parts = (args or "").strip().split(maxsplit=1) + sub = parts[0].lower() if parts else "commands" + + if sub not in cls.SUBCOMMANDS: + io.tool_error(f"Unknown panel: {sub}") + return + + tui = coder.tui() if coder and coder.tui else None + + if tui is None: + io.tool_output(f"/open {sub} is only available in the TUI.") + return + + tui.call_from_thread(tui.open_panel, sub) + + @classmethod + def get_completions(cls, io, coder, args) -> List[str]: + """Complete panel names.""" + prefix = (args or "").strip() + return [sub for sub in cls.SUBCOMMANDS if sub.startswith(prefix)] diff --git a/cecli/helpers/background_commands.py b/cecli/helpers/background_commands.py index 860e439d470..50d59ec5b86 100644 --- a/cecli/helpers/background_commands.py +++ b/cecli/helpers/background_commands.py @@ -825,6 +825,7 @@ def list_background_commands(cls) -> Dict[str, Dict[str, any]]: result[command_key] = { "command": bg_process.command, "running": bg_process.is_alive(), + "persist": getattr(bg_process, "persist", False), "buffer_size": bg_process.buffer.size(), "pages": getattr(bg_process.buffer, "page_count", 0), "total_chars": getattr( diff --git a/cecli/tui/app.py b/cecli/tui/app.py index 0692e8ae317..7a6bdc825cb 100644 --- a/cecli/tui/app.py +++ b/cecli/tui/app.py @@ -29,6 +29,7 @@ from cecli.io import CommandCompletionException from .widgets import ( + BackgroundCommandsPanel, CompletionBar, FileList, FloatingPanels, @@ -68,6 +69,10 @@ class TUI(App): # Binding("escape", "interrupt", "Interrupt", show=True), ] + PANELS = { + "commands": "#background-commands-panel", + } + def __init__(self, coder_worker, output_queue, input_queue, args): """Initialize the cecli TUI app.""" super().__init__() @@ -147,6 +152,7 @@ def __init__(self, coder_worker, output_queue, input_queue, args): ("voice", "start_voice", "Record Voice", True, True), ("paste", "paste_clipboard", "Paste Clipboard", True, True), ("panels", "toggle_floating_panels", "Toggle Panels", True, False), + ("panel_cycle", "cycle_panel", "Cycle Panels", True, False), ) for binding_type, action, description, priority, show in app_bindings: @@ -244,6 +250,7 @@ def _get_config(self): "voice": "ctrl+r", "paste": "alt+v", "panels": "alt+p", + "panel_cycle": "alt+o", "focus": "ctrl+f", "cancel": "ctrl+c", "clear": "ctrl+l", @@ -338,6 +345,8 @@ def compose(self) -> ComposeResult: id="footer", ) + yield BackgroundCommandsPanel(id="background-commands-panel") + BANNER_COLORS = [ "spring_green2", "spring_green1", @@ -803,6 +812,7 @@ def show_error(self, message, agent_name: str | None = None): def on_resize(self) -> None: file_list = self.query_one("#file-list", FileList) file_list.update_files() + self._position_panel() def on_input_area_text_changed(self, message: InputArea.TextChanged): """Handle text changes in input area.""" @@ -938,6 +948,13 @@ def on_input_area_submit(self, message: InputArea.Submit): self._handle_queue_command(stripped) return + if stripped == "/open" or stripped.startswith("/open "): + parts = stripped.split(maxsplit=1) + panel_name = parts[1].strip().lower() if len(parts) > 1 else "commands" + self.query_one("#input", InputArea).value = "" + self.open_panel(panel_name) + return + # Save to history before clearing input_area = self.query_one("#input", InputArea) input_area.save_to_history(user_input) @@ -1252,6 +1269,9 @@ def action_interrupt(self): """ Interrupt the current task, or copy selected text to clipboard. """ + if self._panel_is_visible(): + self.action_close_panel() + return # Determine which coder is in the foreground coder = self.worker.coder if self.worker else None @@ -1299,6 +1319,107 @@ def action_toggle_floating_panels(self): except Exception: pass + def open_panel(self, name: str): + """Show a floating panel by name, centered over the TUI.""" + selector = self.PANELS.get(name) + + if selector is None: + self.show_error(f"Unknown panel: {name}") + return + + try: + panel = self.query_one(selector) + except Exception: + return + + for other_selector in self.PANELS.values(): + if other_selector == selector: + continue + + try: + self.query_one(other_selector).display = False + except Exception: + pass + + panel.display = True + + if hasattr(panel, "refresh_data"): + panel.refresh_data() + + self._position_panel(panel) + self.call_after_refresh(self._position_panel, panel) + + if hasattr(panel, "focus_panel"): + panel.focus_panel() + else: + panel.focus() + + def action_cycle_panel(self): + """Open the first panel, cycle forward, then close after the last.""" + names = list(self.PANELS) + + if not names: + return + + current = None + + for name, selector in self.PANELS.items(): + try: + if self.query_one(selector).display: + current = name + break + except Exception: + pass + + if current is None: + self.open_panel(names[0]) + return + + index = names.index(current) + + if index + 1 >= len(names): + self.action_close_panel() + return + + self.open_panel(names[index + 1]) + + def action_close_panel(self): + """Hide floating panels and return focus to the prompt.""" + for selector in self.PANELS.values(): + try: + self.query_one(selector).display = False + except Exception: + pass + + self.query_one("#input").focus() + + def _panel_is_visible(self) -> bool: + """Return True when any floating panel is displayed.""" + for selector in self.PANELS.values(): + try: + if self.query_one(selector).display: + return True + except Exception: + pass + + return False + + def _position_panel(self, panel=None): + """Center a floating panel over the screen.""" + try: + panel = panel or self.query_one("#background-commands-panel", BackgroundCommandsPanel) + width, height = panel.outer_size + except Exception: + return + + if width <= 0 or height <= 0: + return + + screen_width, screen_height = self.screen.size + x = max(0, (screen_width - width) // 2) + y = max(0, (screen_height - height) // 2) + panel.styles.offset = (x, y) + def action_scroll_panel_up(self): """Scroll the floating status panels up one line.""" self._scroll_floating_panels(-1) @@ -2332,6 +2453,30 @@ def modified_render_ansi(cls, style: Style, color_system: ColorSystem) -> str: textual.strip.Strip.render_ansi = classmethod(cached_version) +def patch_underline_render_without_track(): + """Render tab underlines as a marker only, with no background track.""" + from rich.style import Style + from rich.text import Text + from textual.renderables.bar import Bar + from textual.widgets._tabs import Underline + + def render_without_track(self): + bar_style = self.get_component_rich_style("underline--bar") + start, end = self._highlight_range + + if start == end == 0: + return Text(" " * self.size.width) + + return Bar( + highlight_range=(start, end), + highlight_style=Style.from_color(bar_style.color), + background_style=Style(color="default"), + ) + + Underline.render = render_without_track + + # Execute the patches patch_color_name_to_rgb() +patch_underline_render_without_track() # patch_textual_strip_render_with_cache() diff --git a/cecli/tui/styles.tcss b/cecli/tui/styles.tcss index b69ed096671..a7a405e102b 100644 --- a/cecli/tui/styles.tcss +++ b/cecli/tui/styles.tcss @@ -18,6 +18,7 @@ Screen { background: $background; layout: vertical; + layers: base panels; padding: 0; margin: 0; border: none; @@ -161,3 +162,82 @@ TextArea > .text-area--selection { color: $accent; padding: 0 1; } + +/* -------------------------------------------------------------------------- + * Background commands panel (/open commands) + * -------------------------------------------------------------------------- */ + +#background-commands-panel { + display: none; + position: absolute; + layer: panels; + width: 72%; + max-width: 115; + height: 65%; + border: round $accent 50%; + border-title-align: center; + border-title-color: $accent 50%; + border-title-style: bold; + padding: 0 1; + background: $background; +} + +#bg-command-tabs { + width: 100%; + height: 2; + background: transparent; +} + +#bg-command-tabs, +#bg-command-tabs #tabs-scroll, +#bg-command-tabs #tabs-list-bar, +#bg-command-tabs #tabs-list { + scrollbar-size: 0 0; + scrollbar-visibility: hidden; + scrollbar-background: transparent; + scrollbar-color: transparent; + scrollbar-color-hover: transparent; + scrollbar-color-active: transparent; +} + +#bg-command-tabs Underline > .underline--bar { + color: $accent; +} + +#bg-command-tabs .bg-tab { + background: transparent; + color: $secondary; +} + +#bg-command-tabs .bg-tab.-active { + background: $accent 30%; + color: $accent; + text-style: bold; +} + +#bg-command-tabs .bg-tab:hover { + background: transparent; + color: $foreground; +} + +#bg-command-line { + width: 100%; + height: 1; + color: $secondary; + background: transparent; + padding: 0 1; +} + +#bg-command-empty { + width: 100%; + height: 1fr; + color: $secondary; + content-align: center middle; +} + +#bg-command-output { + width: 100%; + height: 1fr; + background: $background; + scrollbar-size: 1 1; +} diff --git a/cecli/tui/widgets/__init__.py b/cecli/tui/widgets/__init__.py index 09d2e795bb3..481e006442f 100644 --- a/cecli/tui/widgets/__init__.py +++ b/cecli/tui/widgets/__init__.py @@ -1,5 +1,6 @@ """Widgets for the cecli TUI.""" +from .background_commands_panel import BackgroundCommandsPanel from .completion_bar import CompletionBar from .file_list import FileList from .floating_panels import FloatingPanels @@ -12,6 +13,7 @@ from .subagent_pills import SubAgentPills __all__ = [ + "BackgroundCommandsPanel", "MainFooter", "CompletionBar", "InputArea", diff --git a/cecli/tui/widgets/background_commands_panel.py b/cecli/tui/widgets/background_commands_panel.py new file mode 100644 index 00000000000..27bf4ef51c8 --- /dev/null +++ b/cecli/tui/widgets/background_commands_panel.py @@ -0,0 +1,197 @@ +"""Centered read-only panel for active background shell commands.""" + +from typing import Dict, List + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import RichLog, Static, Tab, Tabs + +from cecli.helpers.background_commands import BackgroundCommandManager + + +class BackgroundCommandsPanel(Vertical): + """Task manager overlay with one tab per active background command. + + Tabs are rebuilt from ``BackgroundCommandManager`` on a short interval and + the selected command's output is re-rendered only when its stream offset + changes. Reading output is non-destructive so the agent keeps its own + incremental view of the same buffer. + """ + + REFRESH_INTERVAL = 0.5 + MAX_TAIL_LINES = 500 + + BINDINGS = [ + Binding("f", "toggle_follow", "Follow", show=False), + ] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._active_key = None + self._follow = True + self._keys: List[str] = [] + self._rendered_signature = None + + def compose(self) -> ComposeResult: + yield Tabs(id="bg-command-tabs") + yield Static("", id="bg-command-line") + yield Static("No active background commands.", id="bg-command-empty") + yield RichLog( + id="bg-command-output", + wrap=True, + markup=False, + auto_scroll=False, + max_lines=self.MAX_TAIL_LINES, + ) + + def on_mount(self): + """Start polling and render the first snapshot.""" + self.border_title = "background commands" + self.set_interval(self.REFRESH_INTERVAL, self.refresh_data) + self.call_after_refresh(self.refresh_data) + + def on_tabs_tab_activated(self, event: Tabs.TabActivated): + """Switch the output view to the newly selected tab.""" + if event.tab is None: + return + + self._active_key = event.tab.id + self._rendered_signature = None + self.refresh_data() + + def refresh_data(self): + """Sync tabs and the selected command's output with the manager.""" + if not self.is_mounted or not self._is_shown: + return + + commands = self._active_commands() + self.border_title = ( + f"background commands ({len(commands)})" if commands else "background commands" + ) + + if list(commands) != self._keys: + self._sync_tabs(commands) + + if not commands: + self._active_key = None + self._show_empty(True) + return + + self._show_empty(False) + + if self._active_key not in commands: + self._active_key = next(iter(commands)) + self._select_tab(self._active_key) + self._rendered_signature = None + + active = commands[self._active_key] + self._set_command_line(active) + self._render_output(active) + + def action_toggle_follow(self): + """Toggle tailing the selected command's output.""" + self._follow = not self._follow + self._rendered_signature = None + self.refresh_data() + + def focus_panel(self): + """Focus the tab bar so keyboard navigation reaches this panel.""" + try: + self.query_one("#bg-command-tabs", Tabs).focus() + except Exception: + self.focus() + + @property + def _is_shown(self) -> bool: + """Return True when the panel and its overlay are both displayed.""" + if not self.display: + return False + + return self.parent is None or self.parent.display + + def _active_commands(self) -> Dict[str, dict]: + """Return only running commands started in the background.""" + commands = {} + + for command_key, info in BackgroundCommandManager.list_background_commands().items(): + if not info.get("running"): + continue + + if info.get("persist"): + continue + + commands[command_key] = info + + return commands + + def _sync_tabs(self, commands: Dict[str, dict]) -> None: + """Rebuild the tab bar when the set of active commands changes.""" + tabs = self.query_one("#bg-command-tabs", Tabs) + self._keys = list(commands) + + with self.prevent(Tabs.TabActivated): + tabs.clear() + + for command_key in commands: + tabs.add_tab(Tab(command_key, id=command_key, classes="bg-tab")) + + if self._active_key in commands: + tabs.active = self._active_key + else: + self._active_key = next(iter(commands), None) + if self._active_key is not None: + tabs.active = self._active_key + + def _select_tab(self, command_key: str) -> None: + """Mark a tab active without relying on a user click.""" + try: + tabs = self.query_one("#bg-command-tabs", Tabs) + tabs.active = command_key + except Exception: + pass + + def _show_empty(self, empty: bool) -> None: + """Swap between the empty message and the command view.""" + self.query_one("#bg-command-empty", Static).display = empty + self.query_one("#bg-command-line", Static).display = not empty + self.query_one("#bg-command-tabs", Tabs).display = not empty + self.query_one("#bg-command-output", RichLog).display = not empty + + def _set_command_line(self, info: dict) -> None: + """Show the selected command, truncated to the panel width.""" + command = " ".join((info.get("command") or "").split()) + text = f"command: {command}" if command else "command: (none)" + width = max(20, self.size.width - 6) + + if len(text) > width: + text = text[: width - 1] + "…" + + self.query_one("#bg-command-line", Static).update(Text(text)) + + def _render_output(self, info: dict) -> None: + """Render the selected command's output when its stream advanced.""" + signature = (info.get("total_chars"), info.get("buffer_size")) + + if signature == self._rendered_signature: + return + + self._rendered_signature = signature + output = BackgroundCommandManager.get_command_output(self._active_key) + self._write_output(output) + + def _write_output(self, output: str) -> None: + """Write the newest lines to the log, following the tail if enabled.""" + log = self.query_one("#bg-command-output", RichLog) + log.clear() + + lines = output.splitlines() + + if len(lines) > self.MAX_TAIL_LINES: + lines = lines[-self.MAX_TAIL_LINES :] + + if lines: + log.write("\n".join(lines), scroll_end=self._follow) + else: + log.write(Text("no output yet", style="dim"), scroll_end=self._follow) diff --git a/cecli/website/docs/config/tui.md b/cecli/website/docs/config/tui.md index 1f5e4c23329..c0e5947b52a 100644 --- a/cecli/website/docs/config/tui.md +++ b/cecli/website/docs/config/tui.md @@ -72,6 +72,7 @@ tui-config: main_agent: "alt+ctrl+home" panel_up: "alt+ctrl+up" panel_down: "alt+ctrl+down" + panel_cycle: "alt+o" ``` @@ -103,6 +104,13 @@ The TUI provides customizable key bindings for all major actions. The default ke | Main Agent | `alt+ctrl+home` | Switch back to the main (primary) agent | | Panels Up | `alt+ctrl+up` | Scroll the floating status panels up one line | | Panels Down | `alt+ctrl+down` | Scroll the floating status panels down one line | +| Cycle Panels | `alt+o` | Open the next floating panel type, or close the current panel after the last type | + +#### Floating Panels + +Press `alt+o` or run `/open commands` to open the background command panel. It floats centered over the TUI, leaving the rest of the interface visible, and shows the output of active background shell commands. Tabs labelled with each command's background key switch between commands, the line below the tabs shows the full command, and `f` toggles tail-following the output. Press `escape` to close it. + +`alt+o` cycles through the available panel types. When the last panel type is open, pressing `alt+o` again closes it. #### Customizing Key Bindings diff --git a/tests/tui/test_background_commands_panel.py b/tests/tui/test_background_commands_panel.py new file mode 100644 index 00000000000..20a080d6d75 --- /dev/null +++ b/tests/tui/test_background_commands_panel.py @@ -0,0 +1,301 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import patch + +from textual.app import App, ComposeResult +from textual.widgets import RichLog, Static, Tab, Tabs + +from cecli.commands.open import OpenCommand +from cecli.helpers.background_commands import BackgroundCommandManager +from cecli.tui.widgets.background_commands_panel import BackgroundCommandsPanel + +COMMANDS = { + "bg_0_1111": { + "command": "npm run dev", + "running": True, + "persist": False, + "buffer_size": 40, + "total_chars": 40, + }, + "bg_1_2222": { + "command": "pytest -q", + "running": True, + "persist": False, + "buffer_size": 20, + "total_chars": 20, + }, + "bg_2_3333": { + "command": "cargo build", + "running": False, + "persist": False, + "buffer_size": 10, + "total_chars": 10, + }, + "bg_3_4444": { + "command": "foreground-tool", + "running": True, + "persist": True, + "buffer_size": 10, + "total_chars": 10, + }, +} + +OUTPUTS = { + "bg_0_1111": "ready in 231 ms\nlistening on 5173", + "bg_1_2222": "14 passed\n2 skipped", +} + + +class _PanelApp(App): + CSS = """ + #background-commands-panel { width: 80%; height: 70%; } + #bg-command-tabs { height: 2; } + #bg-command-line { height: 1; } + #bg-command-output { height: 1fr; } + """ + + def compose(self) -> ComposeResult: + yield BackgroundCommandsPanel(id="background-commands-panel") + + +def _log_text(panel: BackgroundCommandsPanel) -> str: + log = panel.query_one("#bg-command-output", RichLog) + return "\n".join(getattr(line, "text", str(line)) for line in log.lines) + + +def _tab_ids(panel: BackgroundCommandsPanel): + tabs = panel.query_one("#bg-command-tabs", Tabs) + return [tab.id for tab in tabs.query(Tab)] + + +async def _run_panel_checks(): + app = _PanelApp() + + with ( + patch.object( + BackgroundCommandManager, + "list_background_commands", + return_value=COMMANDS, + ), + patch.object( + BackgroundCommandManager, + "get_command_output", + side_effect=lambda key: OUTPUTS.get(key, ""), + ), + patch.object( + BackgroundCommandManager, + "get_new_command_output", + side_effect=AssertionError("panel must not consume agent output"), + ), + ): + async with app.run_test(size=(120, 40)) as pilot: + panel = app.query_one("#background-commands-panel", BackgroundCommandsPanel) + panel.refresh_data() + await pilot.pause() + + assert _tab_ids(panel) == ["bg_0_1111", "bg_1_2222"] + + tabs = panel.query_one("#bg-command-tabs", Tabs) + log = panel.query_one("#bg-command-output", RichLog) + assert tabs.region.height == 2 + assert log.region.height > 2 + + command_line = panel.query_one("#bg-command-line", Static) + assert "npm run dev" in str(command_line.render()) + + assert panel.border_title == "background commands (2)" + assert panel._active_key == "bg_0_1111" + assert "listening on 5173" in _log_text(panel) + + tabs = panel.query_one("#bg-command-tabs", Tabs) + tabs.active = "bg_1_2222" + await pilot.pause() + + assert panel._active_key == "bg_1_2222" + assert "2 skipped" in _log_text(panel) + + panel.action_toggle_follow() + await pilot.pause() + assert panel._follow is False + + tabs.active = "bg_0_1111" + await pilot.pause() + assert panel._rendered_signature == (40, 40) + + panel.refresh_data() + await pilot.pause() + assert "listening on 5173" in _log_text(panel) + + +def test_background_commands_panel_tabs_output_and_follow(): + asyncio.run(_run_panel_checks()) + + +def test_background_commands_panel_empty_state(): + app = _PanelApp() + + with patch.object( + BackgroundCommandManager, + "list_background_commands", + return_value={}, + ): + + async def run(): + async with app.run_test(size=(120, 40)) as pilot: + panel = app.query_one("#background-commands-panel", BackgroundCommandsPanel) + panel.refresh_data() + await pilot.pause() + + assert panel.border_title == "background commands" + assert _tab_ids(panel) == [] + assert panel.query_one("#bg-command-empty").display is True + + asyncio.run(run()) + + +def test_open_command_registration_and_completions(): + from cecli.commands.utils.registry import CommandRegistry + + assert CommandRegistry.get_command("open") is OpenCommand + assert OpenCommand.SUBCOMMANDS == ("commands",) + assert OpenCommand.get_completions(None, None, "com") == ["commands"] + + +def test_open_command_routes_to_tui(): + calls = [] + + class FakeTui: + def call_from_thread(self, callback, *args): + callback(*args) + + def open_panel(self, name): + calls.append(name) + + io = SimpleNamespace(tool_output=lambda *a, **k: None, tool_error=lambda *a, **k: None) + coder = SimpleNamespace(tui=lambda: FakeTui()) + + asyncio.run(OpenCommand.execute(io, coder, "commands")) + asyncio.run(OpenCommand.execute(io, coder, "")) + + assert calls == ["commands", "commands"] + + +def test_tui_panel_open_and_close_wiring(): + from cecli.tui.app import TUI + + assert TUI.PANELS["commands"] == "#background-commands-panel" + + panel = SimpleNamespace( + display=False, + refresh_data=lambda: setattr(panel, "refreshed", True), + focus_panel=lambda: setattr(panel, "focused", True), + refreshed=False, + focused=False, + ) + input_area = SimpleNamespace(focus=lambda: setattr(input_area, "focused", True), focused=False) + tui = object.__new__(TUI) + tui.query_one = lambda selector, *args: { + "#background-commands-panel": panel, + "#input": input_area, + }[selector] + tui.call_after_refresh = lambda *args, **kwargs: None + tui._position_panel = lambda panel=None: setattr(tui, "positioned", True) + + TUI.open_panel(tui, "commands") + + assert panel.display is True + assert panel.refreshed is True + assert panel.focused is True + assert tui.positioned is True + + TUI.action_close_panel(tui) + + assert panel.display is False + assert input_area.focused is True + + +def test_tab_underline_patch_removes_track(): + from textual.widgets._tabs import Underline + + from cecli.tui.app import TUI # noqa: F401 (applies the underline render patch) + + assert Underline.render.__name__ == "render_without_track" + + async def run(): + app = _PanelApp() + + with ( + patch.object( + BackgroundCommandManager, + "list_background_commands", + return_value=COMMANDS, + ), + patch.object( + BackgroundCommandManager, + "get_command_output", + return_value="line", + ), + ): + async with app.run_test(size=(120, 40)) as pilot: + panel = app.query_one("#background-commands-panel", BackgroundCommandsPanel) + panel.refresh_data() + panel.query_one("#bg-command-tabs", Tabs).focus() + await pilot.pause() + await pilot.pause() + + underline = panel.query_one("#bg-command-tabs", Tabs).query_one(Underline) + rendered = underline.render() + + assert rendered.background_style.color.is_default + + asyncio.run(run()) + + +def test_tui_panel_cycle_binding_and_action(): + from cecli.tui.app import TUI + + tui = object.__new__(TUI) + tui.args = SimpleNamespace(tui_config=None) + config = tui._get_config() + + assert config["key_bindings"]["panel_cycle"] == "alt+o" + assert hasattr(TUI, "action_cycle_panel") + + first = SimpleNamespace(display=False) + second = SimpleNamespace(display=False) + tui.PANELS = {"commands": "#first-panel", "other": "#second-panel"} + tui.query_one = lambda selector, *args: { + "#first-panel": first, + "#second-panel": second, + }[selector] + + opened = [] + closed = [] + tui.open_panel = lambda name: opened.append(name) + tui.action_close_panel = lambda: closed.append(True) + + TUI.action_cycle_panel(tui) + assert opened == ["commands"] + + first.display = True + TUI.action_cycle_panel(tui) + assert opened == ["commands", "other"] + + first.display = False + second.display = True + TUI.action_cycle_panel(tui) + assert opened == ["commands", "other"] + assert closed == [True] + + +def test_open_command_without_tui(): + messages = [] + io = SimpleNamespace( + tool_output=messages.append, + tool_error=lambda *a, **k: None, + ) + coder = SimpleNamespace(tui=None) + + asyncio.run(OpenCommand.execute(io, coder, "commands")) + + assert messages == ["/open commands is only available in the TUI."] From 2f34cbfdeb53a075ddd3a0dc881b0f86805bfddf Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 1 Nov 2026 00:27:58 -0500 Subject: [PATCH 24/28] Fix tests --- cecli/helpers/onboarding/app.py | 4 +++- tests/helpers/test_onboarding.py | 33 ++++++++++++++++---------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/cecli/helpers/onboarding/app.py b/cecli/helpers/onboarding/app.py index 24709ee27d0..7982023268e 100644 --- a/cecli/helpers/onboarding/app.py +++ b/cecli/helpers/onboarding/app.py @@ -270,7 +270,9 @@ def _on_provider(self, provider) -> None: ): self.push_screen(ApiKeyScreen(provider, env_vars), self._on_api_keys) else: - present = {v: os.environ[v] for v in env_vars if os.environ.get(v)} + present = {} + if provider_needs_key(provider): + present = {v: os.environ[v] for v in env_vars if os.environ.get(v)} self._on_api_keys(present) def _on_api_keys(self, keys) -> None: diff --git a/tests/helpers/test_onboarding.py b/tests/helpers/test_onboarding.py index e2fdf5b7278..23d79ea52e9 100644 --- a/tests/helpers/test_onboarding.py +++ b/tests/helpers/test_onboarding.py @@ -146,25 +146,26 @@ async def test_onboarding_app_skips_key_for_no_key_provider(): _clear_api_keys() app = OnboardingApp(iter_providers()) - async with app.run_test() as pilot: - await pilot.pause(0.2) - - for char in "bedrock": - await pilot.press(char) + with patch("cecli.helpers.onboarding.app.get_models_for_provider", return_value=[]): + async with app.run_test() as pilot: + await pilot.pause(0.2) + + for char in "bedrock": + await pilot.press(char) + await pilot.pause(0.05) + await pilot.press("tab") await pilot.pause(0.05) - await pilot.press("tab") - await pilot.pause(0.05) - await pilot.press("enter") - await pilot.pause(0.2) + await pilot.press("enter") + await pilot.pause(0.2) - assert app.provider and app.provider["slug"] == "bedrock" + assert app.provider and app.provider["slug"] == "bedrock" - for _ in range(80): - await pilot.pause(0.1) - if type(app.screen).__name__ in ("ModelScreen", "ModelManualScreen"): - break - assert type(app.screen).__name__ in ("ModelScreen", "ModelManualScreen") - assert app.api_keys == {} + for _ in range(80): + await pilot.pause(0.1) + if type(app.screen).__name__ in ("ModelScreen", "ModelManualScreen"): + break + assert type(app.screen).__name__ == "ModelManualScreen" + assert app.api_keys == {} @pytest.mark.asyncio From c3bca4a1bc773938cc74b9f08697234e83f53a95 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 1 Nov 2026 07:35:07 -0500 Subject: [PATCH 25/28] Address #728 by making --show-speed more aesthetic --- cecli/coders/base_coder.py | 46 ++++++++++++++++++------------------ cecli/helpers/profiler.py | 48 +++++++++++++++++++++----------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 840c6fd3995..9a72555e2e0 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -4706,9 +4706,6 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None, model=No hit_rate = 0.0 tokens_str = f"{format_tokens(prompt_tokens)} ◇ {hit_rate:.1f}%" tokens_report = f"{tokens_str} ↑ {format_tokens(completion_tokens)} ↓" - tokens_report = self.token_profiler.add_to_usage_report( - tokens_report, self.message_tokens_sent, self.message_tokens_received - ) total_combined_tokens = ( self.total_tokens_sent @@ -4728,31 +4725,34 @@ def calculate_and_show_tokens_and_cost(self, messages, completion=None, model=No total_hit_rate = 0.0 total_stats = f"{format_tokens(total_combined_tokens)} ◇ {total_hit_rate:.1f}% ↑↓" - if not active_model.info.get("input_cost_per_token"): - self.usage_report = tokens_report + " " + total_stats - return + cost_report = total_stats - try: - cost = litellm.completion_cost(completion_response=completion) - except Exception: - cost = 0 + if active_model.info.get("input_cost_per_token"): + try: + cost = litellm.completion_cost(completion_response=completion) + except Exception: + cost = 0 + + if not cost: + cost = self.compute_costs_from_tokens( + prompt_tokens, + completion_tokens, + cache_write_tokens, + cache_hit_tokens, + model=active_model, + ) - if not cost: - cost = self.compute_costs_from_tokens( - prompt_tokens, - completion_tokens, - cache_write_tokens, - cache_hit_tokens, - model=active_model, + self.total_cost += cost + self.message_cost += cost + cost_report = ( + f"${self.format_cost(self.message_cost)} • {total_stats}" + f" ${self.format_cost(self.total_cost)}" ) - self.total_cost += cost - self.message_cost += cost - cost_report = ( - f"${self.format_cost(self.message_cost)} • {total_stats}" - f" ${self.format_cost(self.total_cost)}" - ) self.usage_report = tokens_report + " " + cost_report + self.usage_report = self.token_profiler.add_to_usage_report( + self.usage_report, self.message_tokens_sent, self.message_tokens_received + ) def format_cost(self, value): if value == 0: diff --git a/cecli/helpers/profiler.py b/cecli/helpers/profiler.py index 7f332612fc5..2f2061723c0 100644 --- a/cecli/helpers/profiler.py +++ b/cecli/helpers/profiler.py @@ -77,7 +77,7 @@ def on_error(self) -> None: def get_report(self) -> Optional[str]: """ - Get the formatted speed report (only if enabled). + Get the compact speed report (only if enabled). Returns: Formatted report string, or None if disabled or no data @@ -91,33 +91,30 @@ def get_report(self) -> Optional[str]: else: elapsed = time.time() - self._start_time - # Build the time report - report = f"\nLLM elapsed time: {elapsed:.2f} seconds" + parts = [] + ttft = None + response_time = elapsed - # Add time to first token if available if self._first_token_time is not None: ttft = self._first_token_time - self._start_time - report += f" (TtFT: {ttft:.2f}s)" + response_time = max(0.0, elapsed - ttft) + parts.append(f"• {ttft:.2f}s") - # Add speed information if we have token data - if self._input_tokens > 0 and self._output_tokens > 0: - speed_parts = [] + has_counts = self._input_tokens > 0 and self._output_tokens > 0 - # Prompt processing speed (based on time to first token) - if ttft > 0: - prompt_speed = self._input_tokens / ttft - speed_parts.append(f"{prompt_speed:.0f} prompt tokens/sec") + # Prompt processing speed (based on time to first token) + if ttft is not None and has_counts and ttft > 0: + prompt_speed = self._input_tokens / ttft + parts.append(f"↑ {_format_rate(prompt_speed)}") - # Token generation speed (based on time after first token) - generation_time = elapsed - ttft - if generation_time > 0: - generation_speed = self._output_tokens / generation_time - speed_parts.append(f"{generation_speed:.0f} output tokens/sec") + parts.append(f"◇ {response_time:.2f}s") - if speed_parts: - report += "\nSpeed: " + ", ".join(speed_parts) + # Token generation speed (based on response time by itself) + if ttft is not None and has_counts and response_time > 0: + generation_speed = self._output_tokens / response_time + parts.append(f"↓ {generation_speed:.0f}") - return report + return " ".join(parts) def get_elapsed(self) -> Optional[float]: """ @@ -157,6 +154,15 @@ def add_to_usage_report( speed_report = self.get_report() if speed_report: - return usage_report + speed_report + return f"{usage_report} {speed_report}" return usage_report + + +def _format_rate(value: float) -> str: + """Format a tokens/sec rate compactly, truncating the decimal (6857 -> '6.8k').""" + if value < 1000: + return f"{value:.0f}" + if value < 10000: + return f"{int(value / 100) / 10:.1f}k" + return f"{round(value / 1000)}k" From ee6af95277be83e4bc101078403a98000902e4de Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 1 Nov 2026 10:12:57 -0500 Subject: [PATCH 26/28] fix: restore queued prompt echo and refresh linear-mode tests Echo queued prompt text before generation so it reaches the transcript. Migrate linear-mode tests from removed ls/Command tools to ReadFile/Shell, and fix blank-line lint in AgentService. --- cecli/coders/base_coder.py | 1 + cecli/helpers/agents/service.py | 3 ++- tests/basic/test_linear_mode.py | 26 ++++++++++++++------------ 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/cecli/coders/base_coder.py b/cecli/coders/base_coder.py index 9d3789672d5..5b5840836de 100755 --- a/cecli/coders/base_coder.py +++ b/cecli/coders/base_coder.py @@ -2064,6 +2064,7 @@ def warn_paused(): break self.io.tool_output(f"Processing queued prompt (id: {item['id']})...") + self.io.user_input(item["text"]) try: await self.generate(item["text"], preproc) interrupt_event = getattr(self, "interrupt_event", None) diff --git a/cecli/helpers/agents/service.py b/cecli/helpers/agents/service.py index 90d6996ad20..4025b951923 100644 --- a/cecli/helpers/agents/service.py +++ b/cecli/helpers/agents/service.py @@ -1412,7 +1412,8 @@ def cleanup_all_for_parent(self) -> None: self._uuid_coder_map.pop(parent_uuid, None) if type(self)._primary_agent_uuid == parent_uuid: type(self)._primary_agent_uuid = None - + + def _tui_notify(tui, callback, *args): """Invoke *callback* on the TUI app, from either thread. diff --git a/tests/basic/test_linear_mode.py b/tests/basic/test_linear_mode.py index 59fe8e8e915..ed2a1df2b8e 100644 --- a/tests/basic/test_linear_mode.py +++ b/tests/basic/test_linear_mode.py @@ -161,7 +161,9 @@ async def test_interrupt_returns_to_usable_linear_prompt( async def test_local_tool_round_trip_is_serialized(tmp_path, gpt35_model, monkeypatch, capsys): (tmp_path / "visible.txt").write_text("visible") - tool_call = tool_call_response("local--ls", '{"path":"."}', "call_ls") + tool_call = tool_call_response( + "local--ReadFile", '{"read":[{"file_path":"visible.txt"}]}', "call_read" + ) done = tool_call_response( "local--Yield", '{"summary":"tool round trip complete"}', "call_yield" ) @@ -173,13 +175,13 @@ async def test_local_tool_round_trip_is_serialized(tmp_path, gpt35_model, monkey edit_format="agent", ) - await asyncio.wait_for(coder._run_linear("list the directory"), timeout=5) + await asyncio.wait_for(coder._run_linear("read the file"), timeout=5) transcript = transcript_path.read_text(encoding="utf-8") assert_linear_transcript(captured_terminal(capsys)) - assert "Tool Call: local • ls" in transcript - assert "path: ." in transcript - assert "Listed" in transcript + assert "Tool Call: local • ReadFile" in transcript + assert "visible.txt" in transcript + assert "visible" in transcript assert "tool round trip complete" in transcript @@ -231,17 +233,17 @@ async def test_mcp_tool_round_trip_returns_to_conversation( async def test_confirmation_rejection_returns_to_usable_prompt( tmp_path, gpt35_model, monkeypatch, capsys ): - command_call = tool_call_response( - "local--Command", + shell_call = tool_call_response( + "local--Shell", '{"command":"printf confirmation-sentinel"}', - "call_command", + "call_shell", ) coder, transcript_path = await make_linear_coder( tmp_path, gpt35_model, [ - command_call, + shell_call, tool_call_response( "local--Yield", '{"summary":"first turn complete"}', "call_first_yield" ), @@ -260,14 +262,14 @@ async def test_confirmation_rejection_returns_to_usable_prompt( await asyncio.wait_for(coder._run_linear("request after confirmation"), timeout=5) transcript = transcript_path.read_text(encoding="utf-8") - command_result = next( - message for message in coder.cur_messages if message.get("tool_call_id") == "call_command" + shell_result = next( + message for message in coder.cur_messages if message.get("tool_call_id") == "call_shell" ) assert_linear_transcript(captured_terminal(capsys)) assert coder.io.get_confirmation_acknowledgement() is False assert len(prompts) == 1 assert prompts[0].startswith("Allow execution of this command?") - assert "Command execution skipped by user." in command_result["content"] + assert "Command execution skipped by user." in shell_result["content"] assert transcript.index("first turn complete") < transcript.index("request after confirmation") assert transcript.index("request after confirmation") < transcript.index("second turn complete") From a213a35182a98ef5a1af2846604eea5112cde075 Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 1 Nov 2026 11:16:27 -0500 Subject: [PATCH 27/28] fix: drain non-TUI input queue without global wakeups Replace the PR #697 race loop with a queue helper that scans the per-coder queue once and restores payloads meant for other readers in their original order. Non-TUI prompts poll that helper while the terminal read is outstanding. This avoids the ordering deadlock in the original one-pop implementation and the global wake event's lost-wakeup window, awaits the cancelled prompt before returning, keeps a partially typed prompt in placeholder, and shares the TUI confirmation mapping while normalising string answers. Cover the ordering cases, prompt-open delivery, draft stashing, special confirmation responses, and the linear coder prompt; document the non-TUI queue contract and inbound WebSocket payloads. The merged PR #697 commit is retained in history for attribution. --- cecli/helpers/io_proxy.py | 227 +++++++++++------------ cecli/helpers/queues.py | 42 ++++- cecli/website/docs/config/api.md | 16 ++ cecli/website/docs/scripting.md | 16 +- tests/basic/test_linear_mode.py | 19 +- tests/subagents/test_io_proxy.py | 309 +++++++++++++++++++++---------- tests/unit/test_queues.py | 48 ++++- 7 files changed, 448 insertions(+), 229 deletions(-) diff --git a/cecli/helpers/io_proxy.py b/cecli/helpers/io_proxy.py index d0510afea20..b41554accb7 100644 --- a/cecli/helpers/io_proxy.py +++ b/cecli/helpers/io_proxy.py @@ -18,6 +18,11 @@ logger = logging.getLogger(__name__) +# Non-TUI prompts poll this often for programmatic input. Polling keeps the +# producer side loop-agnostic: queue.Queue is thread-safe, so a driver never +# needs to know which event loop the prompt is running on. +_QUEUE_POLL_INTERVAL = 0.1 + class IOProxy(Generic[T]): """Facade wrapping an InputOutput instance with coder context. @@ -71,70 +76,6 @@ def __init__(self, target: T, coder: Any) -> None: def unwrap(cls, io): return io._target if isinstance(io, cls) else io - # ------------------------------------------------------------------ # - # Per-coder queue helpers (non-TUI mode) - # ------------------------------------------------------------------ # - - def _take_queued(self, key: str): - """Pop this coder's next queue payload containing *key*, else None. - - A payload meant for a different reader — for example a confirmation - seen while waiting for text — is put back so its own waiter can still - consume it. - """ - try: - payload = self._input_queue.get_nowait() - except _queue.Empty: - return None - if isinstance(payload, dict) and key in payload: - return payload - self._input_queue.put(payload) - return None - - async def _terminal_or_queued(self, terminal, take): - """Await *terminal*, returning early when *take()* yields a payload. - - A queued payload is delivered even while the terminal prompt is open: - the read is raced against the per-coder queue wake. That wake event is - global (any coder's push sets it), so re-check this coder's own queue - before racing again. - """ - queued = take() - if queued is not None: - return queued - - if getattr(self, "prompt_session", None) is None: - # ponytail: the InterruptibleInput/input() fallback reads on a - # worker thread and cannot be cancelled, so a push arriving - # mid-read lands on the next get_input() cycle instead of - # preempting this read. Make the fallback cancellable if it ever - # needs instant delivery. - return await terminal() - - from cecli.helpers import queues - - terminal_task = asyncio.create_task(terminal()) - try: - while True: - wake_task = asyncio.create_task(queues.wait_for_input()) - try: - await asyncio.wait( - {terminal_task, wake_task}, return_when=asyncio.FIRST_COMPLETED - ) - finally: - if not wake_task.done(): - wake_task.cancel() - if terminal_task.done(): - # Surfaces the terminal read's own EOFError/SystemExit. - return terminal_task.result() - queued = take() - if queued is not None: - terminal_task.cancel() - return queued - except BaseException: - terminal_task.cancel() - raise - # ------------------------------------------------------------------ # # Intercepted methods — inject coder_uuid into each call # ------------------------------------------------------------------ # @@ -226,10 +167,9 @@ async def get_input(self, *args, **kwargs): proxy's coder, the input is for a sub-agent — route it via AgentService by calling generate() on the sub-agent, then loop. - In non-TUI mode, delivers a queue-pushed answer (from the WebSocket - server, ACP, or AgentService) when one is waiting, otherwise reads the - base InputOutput terminal prompt. The terminal read is raced against - the queue wake so a push is delivered even while the prompt is open. + In non-TUI mode, returns a queue-pushed answer when one is waiting, + otherwise reads the base InputOutput terminal prompt. A push is + delivered even while that prompt is open. Returns: tuple[str, str | None]: (user_input, coder_uuid). @@ -262,26 +202,29 @@ async def get_input(self, *args, **kwargs): return user_input, coder_uuid return (result, None) - # Non-TUI mode: honor a queue-pushed answer, else read the terminal. - def take_text(): - payload = self._take_queued("text") - if payload is None: - return None + # Non-TUI mode: honor a queued answer, else read the terminal prompt. + payload, result = await self._terminal_or_queued( + lambda: self._target.get_input(*args, **kwargs), + key="text", + keep_draft=True, + ) + + if payload is not None: text = payload["text"] self.user_input(text) return text, payload.get("coder_uuid", self._coder_uuid) - result = await self._terminal_or_queued( - lambda: self._target.get_input(*args, **kwargs), take_text - ) if isinstance(result, tuple) and len(result) == 2: return result return (result, None) async def confirm_ask(self, *args, **kwargs): - """Forward confirm_ask — per-coder queue iteration is handled by - TextualInputOutput which now iterates all per-coder queues. + """Confirm through the TUI queues or the terminal prompt. + + TUI mode delegates to ``TextualInputOutput``, which iterates all + per-coder queues. Non-TUI mode honors a queued ``{"confirmed": ...}`` + payload when one is waiting, otherwise reads the terminal prompt. Coders configured with ``skip_cli_confirmations`` (unattended ``wf:`` workflow and ``ws:`` workspace workers) are auto-confirmed. They run @@ -309,46 +252,23 @@ async def confirm_ask(self, *args, **kwargs): if hasattr(self._target, "output_queue"): return await self._target.confirm_ask(*args, **kwargs) - question = args[0] if args else kwargs.get("question") - question_id = (question, kwargs.get("subject")) - group = kwargs.get("group") - group_response = kwargs.get("group_response") - - def take_confirmation(): - payload = self._take_queued("confirmed") - if payload is None: - return None - return self._apply_confirmation( - payload["confirmed"], question_id, group, group_response - ) - - return await self._terminal_or_queued( - lambda: self._target.confirm_ask(*args, **kwargs), take_confirmation + question = args[0] if args else kwargs.get("question", "") + payload, result = await self._terminal_or_queued( + lambda: self._target.confirm_ask(*args, **kwargs), + key="confirmed", ) - def _apply_confirmation(self, response, question_id, group, group_response): - """Map a queued confirmation payload to a result. + if payload is None: + return result - Mirrors TextualInputOutput.confirm_ask's queue handling. - """ - if response == "never": - self.never_prompts.add(question_id) - return False - if response == "tweak": - return "tweak" - if response == "all": - if group: - group.preference = "all" - if group_response: - self.group_responses[group_response] = True - return True - if response == "skip": - if group: - group.preference = "skip" - if group_response: - self.group_responses[group_response] = False - return False - return bool(response) + self.num_user_asks += 1 + return self._apply_confirmation( + payload["confirmed"], + question, + kwargs.get("subject"), + kwargs.get("group"), + kwargs.get("group_response"), + ) async def recreate_input(self, future=None): """Per-coder recreate_input — each coder gets its own input task. @@ -445,6 +365,83 @@ def __setattr__(self, name: str, value: Any) -> None: else: setattr(self._target, name, value) + async def _terminal_or_queued(self, terminal, key, keep_draft=False): + """Return ``(payload, None)`` from the queue, else ``(None, result)``. + + The terminal read is polled against this coder's queue so a push is + delivered even while the prompt is open. A prompt session that cannot + be cancelled (``InterruptibleInput``/``input()``) is awaited as-is; the + queue is checked again on the next prompt. + """ + from cecli.helpers import queues + + payload = queues.pop_coder_input(self._coder_uuid, key) + if payload is not None: + return payload, None + + if getattr(self, "prompt_session", None) is None: + return None, await terminal() + + terminal_task = asyncio.create_task(terminal()) + try: + while True: + done, _ = await asyncio.wait({terminal_task}, timeout=_QUEUE_POLL_INTERVAL) + if done: + return None, terminal_task.result() + + payload = queues.pop_coder_input(self._coder_uuid, key) + if payload is not None: + if keep_draft: + self._stash_prompt_draft() + return payload, None + finally: + if not terminal_task.done(): + terminal_task.cancel() + + try: + await terminal_task + except (asyncio.CancelledError, Exception): + pass + + def _stash_prompt_draft(self): + """Keep text the user was typing when a queued answer preempts the prompt.""" + session = getattr(self._target, "prompt_session", None) + buffer = getattr(getattr(session, "app", None), "current_buffer", None) + draft = getattr(buffer, "text", "") + if draft: + self._target.placeholder = draft + + def _apply_confirmation(self, response, question, subject, group, group_response): + """Map a queued confirmation payload to ``confirm_ask``'s return value.""" + if isinstance(response, str): + response = response.strip().lower() + + if response == "never": + self.never_prompts.add((question, subject)) + return False + + if response == "tweak": + return "tweak" + + if response == "all": + if group: + group.preference = "all" + if group_response: + self.group_responses[group_response] = True + return True + + if response == "skip": + if group: + group.preference = "skip" + if group_response: + self.group_responses[group_response] = False + return False + + if response in ("no", "n", "false", "0"): + return False + + return bool(response) + def _is_workflow_agent(self) -> bool: """Return True if the proxied coder is a workflow (wf:*) sub-agent.""" _ref = getattr(self, "_coder", None) diff --git a/cecli/helpers/queues.py b/cecli/helpers/queues.py index 2ccd59e54f6..b5ff0477dc7 100644 --- a/cecli/helpers/queues.py +++ b/cecli/helpers/queues.py @@ -61,20 +61,56 @@ def get_coder_queue(coder_uuid: str) -> "queue.Queue | None": def push_coder_input(coder_uuid: str, message: str | dict[str, Any]) -> bool: """Push a user input message directly to a coder's input queue. - Accepts either a raw string (text input) or a dict (structured message - like confirmation responses). Wakes any coroutine blocked in - wait_for_input() so consumers react immediately instead of polling. + Accepts either a raw string (treated as a ``{"text": ...}`` payload) or + a dict (structured message like confirmation responses). Wakes any + coroutine blocked in wait_for_input() so consumers react immediately + instead of polling. Returns True if delivered, False if the coder is not registered. """ q = _per_coder_queues.get(coder_uuid) if q is None: return False + + if isinstance(message, str): + message = {"text": message} + q.put(message) wake_input_waiters() return True +def pop_coder_input(coder_uuid: str, key: str) -> dict[str, Any] | None: + """Pop and return the next queued payload for *coder_uuid* containing *key*. + + Payloads meant for another reader — for example a confirmation while a + text prompt is waiting — are restored in their original order. Returns + None when no matching payload is waiting. + """ + q = _per_coder_queues.get(coder_uuid) + if q is None: + return None + + held = [] + matched = None + + for _ in range(q.qsize()): + try: + payload = q.get_nowait() + except queue.Empty: + break + + if matched is None and isinstance(payload, dict) and key in payload: + matched = payload + else: + held.append(payload) + + for payload in held: + q.put(payload) + + return matched + + def get_primary_coder_id() -> str | None: """Get the primary coder UUID (the first one registered). diff --git a/cecli/website/docs/config/api.md b/cecli/website/docs/config/api.md index 07f6e2a708a..432d8ffabce 100644 --- a/cecli/website/docs/config/api.md +++ b/cecli/website/docs/config/api.md @@ -288,6 +288,22 @@ The server then: 1. Echoes a `user_message` notification (see Server → Client Notifications above) 2. Routes the text to the coder input queue for processing +### Confirmation Answers + +Legacy flat messages are accepted alongside JSON-RPC requests: + +```json +{"type": "user_input", "text": "your message", "coder_uuid": ""} +{"type": "confirmation", "confirmed": true, "coder_uuid": ""} +``` + +`confirmed` is a boolean, or one of `"never"`, `"tweak"`, `"all"`, and +`"skip"`. Omit `coder_uuid` to target the primary coder. The answer is queued +for that coder and consumed by its next prompt or confirmation, so a driver can +answer prompts in non-TUI mode without a terminal. + +**Cancel a session:** + **Cancel a session:** ```json diff --git a/cecli/website/docs/scripting.md b/cecli/website/docs/scripting.md index 7451a18bce4..317a43de196 100644 --- a/cecli/website/docs/scripting.md +++ b/cecli/website/docs/scripting.md @@ -63,15 +63,21 @@ your input comes from depends on the mode: - **TUI** (`tui: true`, the default): type into the chat input box. - **Non-TUI** (`--no-tui`, or `tui: false`): input is read from stdin and - re-prompted after every turn. Questions and confirmation prompts from the - agent are answered at the terminal prompt. + re-prompted after every turn. Queued programmatic input is delivered too, + even while the terminal prompt is open. - **Programmatic**: an external driver pushes `{"text": ...}` (and - `{"confirmed": ...}` for yes/no confirmations) onto the coder's input queue. + `{"confirmed": ...}` for confirmations) onto the coder's input queue. The [WebSocket/ACP server](config/api.html) and the AgentService wake path use this route. -Both input paths consume the same per-coder queue, so a headless or -server-driven session can answer prompts with no terminal attached. +Both input paths consume the same per-coder queue. A queued payload is checked +before the terminal prompt and every 100 ms while the prompt is open, so a +driver can answer a prompt without a terminal. If no cancellable prompt session +is available (a dumb terminal, or when prompt_toolkit could not start), the +terminal read blocks and queued input is picked up at the next prompt instead. + +`{"confirmed": ...}` accepts `true`/`false`, or one of the special responses +`"never"`, `"tweak"`, `"all"`, and `"skip"`. ## Python diff --git a/tests/basic/test_linear_mode.py b/tests/basic/test_linear_mode.py index ed2a1df2b8e..8aa507e466a 100644 --- a/tests/basic/test_linear_mode.py +++ b/tests/basic/test_linear_mode.py @@ -8,7 +8,7 @@ import pytest from cecli.coders import Coder -from cecli.helpers import command_queue, coroutines +from cecli.helpers import command_queue, coroutines, queues from cecli.io import InputOutput from cecli.llm import litellm from cecli.mcp import McpServer @@ -491,3 +491,20 @@ def test_linear_output_suppresses_dynamic_spinners(prompt_session): assert io.get_bottom_toolbar() is None finally: io.stop_spinner() + + +async def test_queued_input_is_delivered_to_linear_prompt(tmp_path, gpt35_model, monkeypatch): + coder, _ = await make_linear_coder(tmp_path, gpt35_model, ["unused"], monkeypatch) + io = coder.io._target + + async def blocking_get_input(*args, **kwargs): + await asyncio.sleep(30) + + monkeypatch.setattr(io, "get_input", blocking_get_input) + queues.push_coder_input(coder.uuid, {"text": "queued prompt", "coder_uuid": coder.uuid}) + try: + result = await asyncio.wait_for(coder.get_input(), timeout=5) + + assert result == ("queued prompt", coder.uuid) + finally: + queues.unregister_coder_queue(coder.uuid) diff --git a/tests/subagents/test_io_proxy.py b/tests/subagents/test_io_proxy.py index 26e5670441e..79d016ae58f 100644 --- a/tests/subagents/test_io_proxy.py +++ b/tests/subagents/test_io_proxy.py @@ -3,6 +3,7 @@ """ import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest @@ -187,174 +188,282 @@ async def test_setattr_forwards_to_target(self): assert target.some_attr == "value" + @pytest.mark.asyncio + async def test_confirm_ask_auto_approves_unattended_coder(self): + """A ``skip_cli_confirmations`` coder auto-approves without touching the target. + + Regression: unattended ``wf:``/``ws:`` workers used to forward the + confirmation to the shared Textual IO and block on the input wake from a + foreign event loop, freezing the interactive coder. + """ + from cecli.helpers.io_proxy import IOProxy + + target = MagicMock() + target.confirm_ask = AsyncMock(return_value=False) + + coder = MagicMock() + coder.uuid = "test-uuid" + coder.skip_cli_confirmations = True + + proxy = IOProxy(target, coder) + result = await proxy.confirm_ask("Run tools?") + + assert result is True + target.confirm_ask.assert_not_called() + + @pytest.mark.asyncio + async def test_confirm_ask_forwards_for_interactive_coder(self): + """An interactive coder forwards ``confirm_ask`` with coder_uuid injected.""" + from cecli.helpers.io_proxy import IOProxy + + target = MagicMock() + target.confirm_ask = AsyncMock(return_value=True) + + coder = MagicMock() + coder.uuid = "test-uuid" + coder.skip_cli_confirmations = False + + proxy = IOProxy(target, coder) + result = await proxy.confirm_ask("Run tools?") -class _NonTuiTarget: - """Minimal non-TUI InputOutput stand-in: no ``output_queue`` attribute. + assert result is True + target.confirm_ask.assert_awaited_once_with("Run tools?", coder_uuid="test-uuid") - IOProxy selects the TUI path with ``hasattr(target, "output_queue")``, and a - bare MagicMock answers ``hasattr`` for every name, so a plain object is the - only way to exercise the non-TUI branch. - """ - def __init__(self): - self.prompt_session = None - self.get_input = AsyncMock(return_value="terminal text") - self.confirm_ask = AsyncMock(return_value=True) +class _NonTuiIO: + """Minimal non-TUI InputOutput stand-in (no ``output_queue``).""" + + def __init__(self, prompt_session=None): + self.prompt_session = prompt_session self.never_prompts = set() self.group_responses = {} self.user_inputs = [] + self.num_user_asks = 0 + self.placeholder = None + self.started = asyncio.Event() + self.cleaned = False + + async def get_input(self, *args, **kwargs): + self.started.set() + try: + await asyncio.sleep(30) + finally: + self.cleaned = True + + async def confirm_ask(self, *args, **kwargs): + self.started.set() + try: + await asyncio.sleep(30) + finally: + self.cleaned = True def user_input(self, inp, log_only=True): self.user_inputs.append(inp) +def _non_tui_proxy(coder_uuid, prompt_session=None): + from cecli.helpers.io_proxy import IOProxy + + target = _NonTuiIO(prompt_session) + coder = MagicMock() + coder.uuid = coder_uuid + coder.skip_cli_confirmations = False + return IOProxy(target, coder), target + + class TestIOProxyNonTuiQueue: """Non-TUI IOProxy must consume queue-pushed input and confirmations.""" @pytest.mark.asyncio async def test_get_input_returns_queued_text(self): from cecli.helpers import queues - from cecli.helpers.io_proxy import IOProxy - coder_uuid = "queued-text-uuid" - target = _NonTuiTarget() - coder = MagicMock() - coder.uuid = coder_uuid - proxy = IOProxy(target, coder) + coder_uuid = "non-tui-text" + proxy, target = _non_tui_proxy(coder_uuid) try: - assert queues.push_coder_input( - coder_uuid, {"text": "queued answer", "coder_uuid": coder_uuid} - ) - result = await proxy.get_input(None, [], [], []) + assert queues.push_coder_input(coder_uuid, {"text": "queued answer"}) + result = await asyncio.wait_for(proxy.get_input(None, [], [], []), timeout=1) assert result == ("queued answer", coder_uuid) - target.get_input.assert_not_called() assert target.user_inputs == ["queued answer"] + assert not target.started.is_set() finally: queues.unregister_coder_queue(coder_uuid) @pytest.mark.asyncio - async def test_get_input_delivers_push_while_prompt_open(self): + async def test_get_input_falls_back_to_terminal(self): from cecli.helpers import queues from cecli.helpers.io_proxy import IOProxy - coder_uuid = "queued-race-uuid" - started = asyncio.Event() - - async def blocking_terminal(*args, **kwargs): - started.set() - await asyncio.sleep(30) - return "terminal text" - - target = _NonTuiTarget() - target.prompt_session = object() # cancellable prompt_async path - target.get_input = blocking_terminal + coder_uuid = "non-tui-terminal" + target = _NonTuiIO() + target.get_input = AsyncMock(return_value="terminal text") coder = MagicMock() coder.uuid = coder_uuid proxy = IOProxy(target, coder) + try: + assert await proxy.get_input() == ("terminal text", None) + finally: + queues.unregister_coder_queue(coder_uuid) + + @pytest.mark.asyncio + async def test_push_delivered_while_prompt_open(self): + from cecli.helpers import queues + + coder_uuid = "non-tui-race" + proxy, target = _non_tui_proxy(coder_uuid, prompt_session=object()) task = asyncio.create_task(proxy.get_input(None, [], [], [])) try: - await started.wait() - # The queue waiter clears its wake event when it binds to the loop, - # so push until delivery lands instead of betting on callback order. - for _ in range(100): - queues.push_coder_input( - coder_uuid, {"text": "pushed answer", "coder_uuid": coder_uuid} - ) - try: - result = await asyncio.wait_for(asyncio.shield(task), timeout=0.05) - break - except asyncio.TimeoutError: - continue - else: - raise AssertionError("queued input was never delivered") + await asyncio.wait_for(target.started.wait(), timeout=1) + assert queues.push_coder_input(coder_uuid, {"text": "pushed answer"}) + result = await asyncio.wait_for(task, timeout=1) assert result == ("pushed answer", coder_uuid) + assert target.cleaned finally: task.cancel() queues.unregister_coder_queue(coder_uuid) + @pytest.mark.asyncio + async def test_text_is_delivered_behind_a_queued_confirmation(self): + from cecli.helpers import queues + + coder_uuid = "non-tui-order-confirm-first" + proxy, _ = _non_tui_proxy(coder_uuid, prompt_session=object()) + try: + queues.push_coder_input(coder_uuid, {"confirmed": True}) + queues.push_coder_input(coder_uuid, {"text": "answer"}) + + result = await asyncio.wait_for(proxy.get_input(None, [], [], []), timeout=1) + + assert result == ("answer", coder_uuid) + assert queues.get_coder_queue(coder_uuid).qsize() == 1 + finally: + queues.unregister_coder_queue(coder_uuid) + + @pytest.mark.asyncio + async def test_confirmation_is_delivered_behind_queued_text(self): + from cecli.helpers import queues + + coder_uuid = "non-tui-order-text-first" + proxy, _ = _non_tui_proxy(coder_uuid, prompt_session=object()) + try: + queues.push_coder_input(coder_uuid, {"text": "answer"}) + queues.push_coder_input(coder_uuid, {"confirmed": False}) + + assert await asyncio.wait_for(proxy.confirm_ask("Proceed?"), timeout=1) is False + assert queues.get_coder_queue(coder_uuid).qsize() == 1 + finally: + queues.unregister_coder_queue(coder_uuid) + @pytest.mark.asyncio async def test_get_input_leaves_confirmations_for_confirm_ask(self): from cecli.helpers import queues - from cecli.helpers.io_proxy import IOProxy - coder_uuid = "queued-mixed-uuid" - target = _NonTuiTarget() - coder = MagicMock() - coder.uuid = coder_uuid - proxy = IOProxy(target, coder) + coder_uuid = "non-tui-leave-confirmation" + proxy, target = _non_tui_proxy(coder_uuid, prompt_session=object()) + task = asyncio.create_task(proxy.get_input(None, [], [], [])) try: - assert queues.push_coder_input( - coder_uuid, {"confirmed": True, "coder_uuid": coder_uuid} - ) - # get_input must not swallow a confirmation it cannot use. - assert await proxy.get_input(None, [], [], []) == ("terminal text", None) + await asyncio.wait_for(target.started.wait(), timeout=1) + queues.push_coder_input(coder_uuid, {"confirmed": True}) + await asyncio.sleep(0.2) + + assert not task.done() + assert queues.get_coder_queue(coder_uuid).qsize() == 1 + + queues.push_coder_input(coder_uuid, {"text": "answer"}) + assert await asyncio.wait_for(task, timeout=1) == ("answer", coder_uuid) assert await proxy.confirm_ask("Proceed?") is True finally: + task.cancel() queues.unregister_coder_queue(coder_uuid) @pytest.mark.asyncio - async def test_confirm_ask_returns_queued_confirmation(self): + async def test_confirm_ask_returns_queued_booleans(self): from cecli.helpers import queues - from cecli.helpers.io_proxy import IOProxy - coder_uuid = "queued-confirm-uuid" - target = _NonTuiTarget() - coder = MagicMock() - coder.uuid = coder_uuid - proxy = IOProxy(target, coder) + coder_uuid = "non-tui-confirm-bools" + proxy, target = _non_tui_proxy(coder_uuid) try: - assert queues.push_coder_input( - coder_uuid, {"confirmed": True, "coder_uuid": coder_uuid} - ) + queues.push_coder_input(coder_uuid, {"confirmed": True}) assert await proxy.confirm_ask("Proceed?") is True - assert queues.push_coder_input( - coder_uuid, {"confirmed": False, "coder_uuid": coder_uuid} - ) + queues.push_coder_input(coder_uuid, {"confirmed": False}) assert await proxy.confirm_ask("Proceed?") is False - target.confirm_ask.assert_not_called() + queues.push_coder_input(coder_uuid, {"confirmed": "no"}) + assert await proxy.confirm_ask("Proceed?") is False + + assert target.num_user_asks == 3 + assert not target.started.is_set() finally: queues.unregister_coder_queue(coder_uuid) + @pytest.mark.asyncio - async def test_confirm_ask_auto_approves_unattended_coder(self): - """A ``skip_cli_confirmations`` coder auto-approves without touching the target. + async def test_confirm_ask_maps_special_responses(self): + from cecli.helpers import queues - Regression: unattended ``wf:``/``ws:`` workers used to forward the - confirmation to the shared Textual IO and block on the input wake from a - foreign event loop, freezing the interactive coder. - """ - from cecli.helpers.io_proxy import IOProxy + coder_uuid = "non-tui-confirm-special" + proxy, target = _non_tui_proxy(coder_uuid) + group = SimpleNamespace(preference=None, show_group=True) + try: + queues.push_coder_input(coder_uuid, {"confirmed": "never"}) + assert await proxy.confirm_ask("Proceed?", subject="file.py") is False + assert ("Proceed?", "file.py") in target.never_prompts + + queues.push_coder_input(coder_uuid, {"confirmed": "tweak"}) + assert await proxy.confirm_ask("Proceed?", allow_tweak=True) == "tweak" + + queues.push_coder_input(coder_uuid, {"confirmed": "all"}) + result = await proxy.confirm_ask("Proceed?", group=group, group_response="batch") + assert result is True + assert group.preference == "all" + assert target.group_responses["batch"] is True + + queues.push_coder_input(coder_uuid, {"confirmed": "skip"}) + result = await proxy.confirm_ask("Proceed?", group=group, group_response="batch") + assert result is False + assert group.preference == "skip" + assert target.group_responses["batch"] is False + finally: + queues.unregister_coder_queue(coder_uuid) - target = MagicMock() - target.confirm_ask = AsyncMock(return_value=False) + @pytest.mark.asyncio + async def test_unattended_coder_does_not_consume_queued_confirmation(self): + from cecli.helpers import queues + from cecli.helpers.io_proxy import IOProxy + coder_uuid = "non-tui-unattended" + target = _NonTuiIO() coder = MagicMock() - coder.uuid = "test-uuid" + coder.uuid = coder_uuid coder.skip_cli_confirmations = True - proxy = IOProxy(target, coder) - result = await proxy.confirm_ask("Run tools?") + try: + queues.push_coder_input(coder_uuid, {"confirmed": False}) - assert result is True - target.confirm_ask.assert_not_called() + assert await proxy.confirm_ask("Proceed?") is True + assert queues.get_coder_queue(coder_uuid).qsize() == 1 + assert not target.started.is_set() + finally: + queues.unregister_coder_queue(coder_uuid) @pytest.mark.asyncio - async def test_confirm_ask_forwards_for_interactive_coder(self): - """An interactive coder forwards ``confirm_ask`` with coder_uuid injected.""" - from cecli.helpers.io_proxy import IOProxy - - target = MagicMock() - target.confirm_ask = AsyncMock(return_value=True) - - coder = MagicMock() - coder.uuid = "test-uuid" - coder.skip_cli_confirmations = False + async def test_prompt_draft_is_stashed_when_queue_preempts(self): + from cecli.helpers import queues - proxy = IOProxy(target, coder) - result = await proxy.confirm_ask("Run tools?") + coder_uuid = "non-tui-draft" + session = SimpleNamespace( + app=SimpleNamespace(current_buffer=SimpleNamespace(text="half typed")) + ) + proxy, target = _non_tui_proxy(coder_uuid, prompt_session=session) + task = asyncio.create_task(proxy.get_input(None, [], [], [])) + try: + await asyncio.wait_for(target.started.wait(), timeout=1) + queues.push_coder_input(coder_uuid, {"text": "queued"}) - assert result is True - target.confirm_ask.assert_awaited_once_with("Run tools?", coder_uuid="test-uuid") + await asyncio.wait_for(task, timeout=1) + assert target.placeholder == "half typed" + finally: + task.cancel() + queues.unregister_coder_queue(coder_uuid) diff --git a/tests/unit/test_queues.py b/tests/unit/test_queues.py index af2c38f506f..bffc473bf19 100644 --- a/tests/unit/test_queues.py +++ b/tests/unit/test_queues.py @@ -83,12 +83,12 @@ def setup_method(self): queues._per_coder_queues.clear() def test_push_string_message(self): - """Pushing a string message delivers it to the correct queue.""" + """Pushing a string message wraps it as a text payload.""" q = queue.Queue() queues.register_coder_queue("coder-1", q) result = queues.push_coder_input("coder-1", "hello") assert result is True - assert q.get_nowait() == "hello" + assert q.get_nowait() == {"text": "hello"} def test_push_dict_message(self): """Pushing a dict message delivers it to the correct queue.""" @@ -110,8 +110,46 @@ def test_push_preserves_queue_order(self): queues.register_coder_queue("coder-1", q) queues.push_coder_input("coder-1", "first") queues.push_coder_input("coder-1", "second") - assert q.get_nowait() == "first" - assert q.get_nowait() == "second" + assert q.get_nowait() == {"text": "first"} + assert q.get_nowait() == {"text": "second"} + + +class TestPopCoderInput: + """Tests for pop_coder_input.""" + + def setup_method(self): + queues._per_coder_queues.clear() + + def test_pop_returns_matching_payload(self): + """The next payload containing the key is returned and removed.""" + queues.register_coder_queue("coder-1", queue.Queue()) + queues.push_coder_input("coder-1", {"text": "first"}) + queues.push_coder_input("coder-1", {"text": "second"}) + + assert queues.pop_coder_input("coder-1", "text") == {"text": "first"} + assert queues.pop_coder_input("coder-1", "text") == {"text": "second"} + assert queues.pop_coder_input("coder-1", "text") is None + + def test_pop_skips_payloads_for_other_readers(self): + """Non-matching payloads stay queued in their original order.""" + q = queue.Queue() + queues.register_coder_queue("coder-1", q) + queues.push_coder_input("coder-1", {"confirmed": True}) + queues.push_coder_input("coder-1", {"text": "answer"}) + queues.push_coder_input("coder-1", {"confirmed": False}) + + assert queues.pop_coder_input("coder-1", "text") == {"text": "answer"} + assert q.get_nowait() == {"confirmed": True} + assert q.get_nowait() == {"confirmed": False} + + def test_pop_unknown_coder(self): + """An unregistered coder has no payloads to pop.""" + assert queues.pop_coder_input("does-not-exist", "text") is None + + def test_pop_empty_queue(self): + """An empty queue returns None.""" + queues.register_coder_queue("coder-1", queue.Queue()) + assert queues.pop_coder_input("coder-1", "text") is None class TestInputLoopLifecycle: @@ -155,7 +193,7 @@ def test_push_after_closed_loop_delivers_but_does_not_crash(self): result = queues.push_coder_input("coder-1", "hello") assert result is True - assert q.get_nowait() == "hello" + assert q.get_nowait() == {"text": "hello"} assert queues._input_loop is None assert queues._input_wake is None From 8a36a68f740e7814c0b8c286f009666ec7c3032c Mon Sep 17 00:00:00 2001 From: Dustin Washington Date: Sun, 1 Nov 2026 11:39:46 -0500 Subject: [PATCH 28/28] Make test robust in CI/CD pipeline --- tests/mcp/test_keepalive_integration.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/mcp/test_keepalive_integration.py b/tests/mcp/test_keepalive_integration.py index 47a6fb615ba..1cdf360737e 100644 --- a/tests/mcp/test_keepalive_integration.py +++ b/tests/mcp/test_keepalive_integration.py @@ -24,8 +24,9 @@ async def test_options_requests_sent_periodically(self, http_based_server, runni # Verify keepalive task is running assert inspector.is_keepalive_running(server) - # Wait for at least one keepalive interval (1 second) - await asyncio.sleep(1.2) + # Wait for the first keepalive ping, polling so a late ping on a + # loaded runner cannot trip the assertion. + await self._wait_for_requests(running_mock_server) # Verify mock server received requests assert running_mock_server.request_count >= 1 @@ -168,7 +169,17 @@ async def test_streaming_server_keepalive_also_works( assert inspector.is_keepalive_running(server) - await asyncio.sleep(1.2) + await self._wait_for_requests(running_mock_server) + assert running_mock_server.request_count >= 1 await server.disconnect() + + @staticmethod + async def _wait_for_requests(mock_server, timeout: float = 5.0) -> None: + """Poll until the mock server has received at least one request.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + + while mock_server.request_count < 1 and loop.time() < deadline: + await asyncio.sleep(0.05)