Skip to content
107 changes: 80 additions & 27 deletions src/kimi_cli/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import asyncio
import contextlib
import os
import shlex
import sys
import time
from collections import deque
from collections.abc import Awaitable, Callable, Coroutine
Expand All @@ -22,6 +24,12 @@
from kimi_cli.soul import LLMNotSet, LLMNotSupported, MaxStepsReached, RunCancelled, Soul, run_soul
from kimi_cli.soul.kimisoul import KimiSoul
from kimi_cli.ui.shell import update as _update_mod
from kimi_cli.ui.shell.capture import inject_to_context

if sys.platform != "win32":
from kimi_cli.ui.shell.capture import execute_with_pty_capture as _execute_capture
else:
from kimi_cli.ui.shell.capture import execute_with_pipe_capture as _execute_capture
from kimi_cli.ui.shell.console import console
from kimi_cli.ui.shell.echo import render_user_echo_text
from kimi_cli.ui.shell.mcp_status import render_mcp_prompt
Expand All @@ -40,7 +48,6 @@
visualize,
)
from kimi_cli.utils.envvar import get_env_bool
from kimi_cli.utils.logging import open_original_stderr
from kimi_cli.utils.signals import install_sigint_handler
from kimi_cli.utils.slashcmd import SlashCommand, SlashCommandCall, parse_slash_command_call
from kimi_cli.utils.subprocess_env import get_clean_env
Expand Down Expand Up @@ -487,7 +494,7 @@ async def _invalidate_after_mcp_loading() -> None:
return shell_ok

async def _run_shell_command(self, command: str) -> None:
"""Run a shell command in foreground."""
"""Run a shell command in foreground, capturing output for context injection."""
if not command.strip():
return

Expand All @@ -503,45 +510,91 @@ async def _run_shell_command(self, command: str) -> None:
)
return

# Check if user is trying to use 'cd' command
# Handle bare `cd` / `cd <path>` — resolve and persist globally.
# Compound commands like `cd /tmp && ls` are left to the shell.
stripped_cmd = command.strip()
split_cmd: list[str] | None = None
try:
split_cmd = shlex.split(stripped_cmd)
except ValueError as exc:
logger.debug("Failed to parse shell command for cd check: {error}", error=exc)
if split_cmd and len(split_cmd) == 2 and split_cmd[0] == "cd":
console.print(
"[yellow]Warning: Directory changes are not preserved across command executions."
"[/yellow]"
)
if split_cmd and split_cmd[0] == "cd" and len(split_cmd) <= 2:
await self._handle_cd(split_cmd)
Comment on lines +521 to +522
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Skip cd interception when shell operators are attached

The len(split_cmd) <= 2 gate still captures commands like cd /tmp&&pwd or cd ~/x;ls because shlex.split keeps &&/; inside the second token when no spaces are present. Those are valid shell command chains, but this branch routes them into _handle_cd instead of executing normally: for quoted targets it fails as cd '/tmp&&pwd', and for ~/$ targets it can execute extra probe-side commands with output swallowed. This is a functional regression for common no-space shell syntax.

Useful? React with 👍 / 👎.

return

logger.info("Running shell command: {cmd}", cmd=command)

proc: asyncio.subprocess.Process | None = None

def _handler():
logger.debug("SIGINT received.")
if proc:
proc.terminate()

loop = asyncio.get_running_loop()
remove_sigint = install_sigint_handler(loop, _handler)
exit_code: int | None = None
raw_output: str | None = None
try:
# TODO: For the sake of simplicity, we now use `create_subprocess_shell`.
# Later we should consider making this behave like a real shell.
with open_original_stderr() as stderr:
kwargs: dict[str, Any] = {}
if stderr is not None:
kwargs["stderr"] = stderr
proc = await asyncio.create_subprocess_shell(command, env=get_clean_env(), **kwargs)
await proc.wait()
exit_code, raw_output = await _execute_capture(
command, env=get_clean_env(), cwd=os.getcwd()
)
except Exception as e:
logger.exception("Failed to run shell command:")
console.print(f"[red]Failed to run shell command: {e}[/red]")
finally:
remove_sigint()

# Inject captured output into conversation context
if raw_output is not None and isinstance(self.soul, KimiSoul):
try:
await inject_to_context(
self.soul.context.append_message, command, raw_output, exit_code
)
except Exception:
logger.debug("Failed to inject shell output to context", exc_info=True)

async def _handle_cd(self, args: list[str]) -> None:
"""Resolve ``cd`` via a real shell and persist the directory change.

Only called for bare ``cd`` / ``cd <path>`` (at most 2 tokens).
"""
target = args[1] if len(args) > 1 else "~"

# Provide OLDPWD so `cd -` works across invocations.
env = get_clean_env()
old_cwd = os.getcwd()
if "OLDPWD" not in env:
env["OLDPWD"] = old_cwd

# Use shlex.quote for safety, but NOT for targets that need shell
# expansion: ~, -, and $VAR references. For those we let the real
# shell handle expansion directly.
needs_shell_expansion = target.startswith("~") or target.startswith("$") or target == "-"
quoted_target = target if needs_shell_expansion else shlex.quote(target)
Comment on lines +562 to +563
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Unquoted tilde/dollar targets in _handle_cd break paths containing spaces

The PR removed the Python-side os.path.expanduser() call for ~ targets and replaced it with passing the target unquoted to the shell when it starts with ~, $, or equals -. This regresses cd for paths containing spaces. For example, cd "~/My Documents" (shlex.split → ['cd', '~/My Documents']) previously worked because the old code expanded ~ with os.path.expanduser (→ /home/user/My Documents) then safely quoted it with shlex.quote. Now the unquoted target produces the shell command cd ~/My Documents && pwd, where the shell sees ~/My and Documents as separate words, causing cd to fail.

Reproduction trace

Old code path: target="~/My Documents"os.path.expanduser(...)"/home/user/My Documents"shlex.quote(...)"'/home/user/My Documents'" → shell: cd '/home/user/My Documents' && pwd → ✅

New code path: target="~/My Documents"needs_shell_expansion=Truequoted_target="~/My Documents" (unquoted) → shell: cd ~/My Documents && pwd → ❌ (word-split by shell)

Prompt for agents
In src/kimi_cli/ui/shell/__init__.py, lines 559-567 in _handle_cd: the current approach of passing tilde and dollar targets unquoted to the shell breaks when the target contains spaces (e.g. cd ~/My Documents). The fix should properly handle shell expansion while also quoting for spaces. One approach is to use shlex.quote on the target but selectively handle tilde expansion: for ~ targets, expand on the Python side with os.path.expanduser first (as the old code did), then shlex.quote the result. For $ targets, you could expand with os.path.expandvars first, then shlex.quote. For - targets, shlex.quote('-') works fine. This ensures both shell-like expansion AND proper handling of spaces/special characters.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +562 to +563
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve quoting when resolving expandable cd targets

Because shlex.split() removes the user's original quotes, this branch emits target unquoted whenever it starts with ~ or $, then splices it into cd {quoted_target} && pwd. That breaks valid inputs like cd ~/'tmp space' or cd "$HOME/tmp space" (the probe sees multiple args and fails), and it can also reinterpret quoted literals containing shell metacharacters. The persistent-cd path should not drop quoting semantics for expansion cases.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve quoting for expandable cd targets

For targets starting with ~ or $, the code intentionally bypasses shlex.quote and injects the raw token into the probe command, which loses the user's quoting context. Valid commands like cd ~/"Application Support" or cd "$HOME/My Folder" become unquoted (cd ~/Application Support && pwd), get word-split by the shell, and fail even though the original command is valid.

Useful? React with 👍 / 👎.

Comment on lines +562 to +563
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve env expansion beyond first character in cd target

The cd resolver only treats targets as expandable when target.startswith("$"), so commands like cd src/$MODULE or cd ./$PROJECT are incorrectly quoted and passed to the shell as literals (e.g. cd 'src/$MODULE'), which prevents variable expansion and makes valid shell paths fail. This is a functional regression in the new persistent-cd flow for users who compose paths with env vars outside the first character.

Useful? React with 👍 / 👎.


# Let the shell resolve ~, -, $HOME, CDPATH, etc.
probe = await asyncio.create_subprocess_shell(
f"cd {quoted_target} && pwd",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replace POSIX-only pwd in cd resolver

The cd probe command is hardcoded as cd {quoted_target} && pwd, which assumes a POSIX shell, but _handle_cd is called on all platforms. In Windows shell mode, asyncio.create_subprocess_shell runs via cmd.exe, where pwd is not a builtin, so the probe fails and every cd command is treated as an error; as a result, os.chdir() and session.work_dir are never updated for Windows users.

Useful? React with 👍 / 👎.

stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
stdout, stderr = await probe.communicate()

if probe.returncode != 0:
err = stderr.decode("utf-8", errors="replace").strip()
console.print(f"[red]cd: {err or 'failed'}[/red]")
return

# `cd -` prints the destination before `pwd`; take the last non-empty line.
lines = [ln for ln in stdout.decode("utf-8", errors="replace").splitlines() if ln.strip()]
new_cwd = lines[-1].strip() if lines else ""
if not new_cwd or not os.path.isdir(new_cwd):
console.print(f"[red]cd: not a directory: {target}[/red]")
return

os.chdir(new_cwd)
# Set OLDPWD for the next `cd -` invocation.
os.environ["OLDPWD"] = old_cwd

# Keep the session's work_dir in sync so agent background tasks
# (which use session.work_dir as cwd) also see the new directory.
if isinstance(self.soul, KimiSoul):
from kaos.path import KaosPath

self.soul.runtime.session.work_dir = KaosPath.unsafe_from_local_path(
__import__("pathlib").Path(new_cwd)
)
Comment on lines +546 to +597
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot Mar 25, 2026

Choose a reason for hiding this comment

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

🔴 Unhandled exceptions in _handle_cd crash the interactive shell session

The _handle_cd method performs several fallible operations — os.getcwd() (line 555), asyncio.create_subprocess_shell (line 566), probe.communicate() (line 572), and os.chdir() (line 586) — but none are wrapped in try/except. The call site at src/kimi_cli/ui/shell/__init__.py:522 also lacks error handling. Any exception propagates through _run_shell_command into the main event loop (src/kimi_cli/ui/shell/__init__.py:453-454), which has only try/finally but no except, crashing the entire interactive session.

This contrasts with the general shell command path which wraps _execute_capture in try/except Exception at src/kimi_cli/ui/shell/__init__.py:529-535. A practical trigger: if the current working directory has been deleted (common during development), os.getcwd() at line 555 raises FileNotFoundError, killing the session instead of showing a friendly error.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


async def _run_slash_command(self, command_call: SlashCommandCall) -> None:
from kimi_cli.cli import Reload, SwitchToWeb
Expand Down
Loading
Loading