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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/kimi_cli/tools/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ async def _stream_subprocess(
stderr_cb: Callable[[bytes], None],
timeout: int,
) -> int:
if platform.system() == "Windows":
# Use cmd.exe explicitly so Windows syntax (e.g., %VAR%) works.
args = ["cmd.exe", "/d", "/s", "/c", command]
else:
# Use bash for POSIX features relied on by tests (pipes, env, etc.).
args = ["bash", "-c", command]

async def _read_stream(stream: asyncio.StreamReader, cb: Callable[[bytes], None]):
while True:
line = await stream.readline()
Expand All @@ -91,8 +98,8 @@ async def _read_stream(stream: asyncio.StreamReader, cb: Callable[[bytes], None]
break

# FIXME: if the event loop is cancelled, an exception may be raised when the process finishes
process = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
process = await asyncio.create_subprocess_exec(
*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)

assert process.stdout is not None, "stdout is None"
Expand Down
72 changes: 72 additions & 0 deletions tests/test_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Basic Windows cmd tests for the shell tool."""
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The module docstring says "Basic Windows cmd tests" but test_bash.py uses "Tests for the shell tool." For consistency, consider using a similar format like "Tests for the shell tool on Windows." or "Windows cmd tests for the shell tool."

Suggested change
"""Basic Windows cmd tests for the shell tool."""
"""Tests for the shell tool on Windows."""

Copilot uses AI. Check for mistakes.

from __future__ import annotations

import platform

import pytest
from inline_snapshot import snapshot
from kosong.tooling import ToolError, ToolOk

from kaos.path import KaosPath
from kimi_cli.tools.shell import Params, Shell

pytestmark = pytest.mark.skipif(
platform.system() != "Windows", reason="Shell cmd tests run only on Windows."
)


@pytest.mark.asyncio
async def test_simple_command(shell_tool: Shell):
"""Ensure a basic cmd command runs."""
result = await shell_tool(Params(command="echo Hello Windows"))

assert isinstance(result, ToolOk)
assert isinstance(result.output, str)
assert result.output.strip() == snapshot("Hello Windows")
assert "Command executed successfully" in result.message


@pytest.mark.asyncio
async def test_command_with_error(shell_tool: Shell):
"""Failing commands should return a ToolError with exit code info."""
result = await shell_tool(Params(command='python -c "import sys; sys.exit(1)"'))
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This test assumes Python is available in the PATH on Windows. For more robust testing, consider using a built-in Windows command that's guaranteed to fail, such as dir C:\nonexistent\directory (similar to the bash test using ls /nonexistent/directory). This would make the test more portable and not dependent on Python being installed.

Suggested change
result = await shell_tool(Params(command='python -c "import sys; sys.exit(1)"'))
result = await shell_tool(Params(command='dir C:\\nonexistent\\directory'))

Copilot uses AI. Check for mistakes.

assert isinstance(result, ToolError)
assert result.output == snapshot("")
assert "Command failed with exit code: 1" in result.message
assert "Failed with exit code: 1" in result.brief


@pytest.mark.asyncio
async def test_command_chaining(shell_tool: Shell):
"""Chaining commands with && should work."""
result = await shell_tool(Params(command="echo First&& echo Second"))

assert isinstance(result, ToolOk)
assert isinstance(result.output, str)
assert result.output.replace("\r\n", "\n") == snapshot("First\nSecond\n")


@pytest.mark.asyncio
async def test_environment_variables(shell_tool: Shell):
"""Environment variables should be usable within one cmd session."""
result = await shell_tool(Params(command="set TEST_VAR=test_value&& echo %TEST_VAR%"))

assert isinstance(result, ToolOk)
assert isinstance(result.output, str)
assert result.output.strip() == snapshot("test_value")


@pytest.mark.asyncio
async def test_file_operations(shell_tool: Shell, temp_work_dir: KaosPath):
"""Basic file write/read using cmd redirection."""
file_path = temp_work_dir / "test_file.txt"

create_result = await shell_tool(Params(command=f'echo Test content> "{file_path}"'))
assert create_result == snapshot(ToolOk(output="", message="Command executed successfully."))

read_result = await shell_tool(Params(command=f'type "{file_path}"'))
assert read_result == snapshot(
ToolOk(output="Test content\r\n", message="Command executed successfully.")
)
Loading