Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .github/workflows/python-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
python: ["3.10", "3.13"]
python: ["3.10", "3.13", "3.14"]
include:
# Launcher resolution and option tests; fake-CLI process tests are POSIX-only.
- os: windows-latest
python: "3.13"
runs-on: ${{ matrix.os }}
defaults:
run:
Expand All @@ -38,3 +42,6 @@ jobs:
- run: uv run ruff format --check .
- run: uv run mypy
- run: uv run pytest -W error
env:
# Fail on implicit text encodings, which use the locale code page on Windows.
PYTHONWARNDEFAULTENCODING: "1"
17 changes: 17 additions & 0 deletions packages/code-sdk-python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ versioned independently of the MiniMax Code CLI and of the TypeScript SDK. The f
- SDK errors survive pickling: `MCodeRunError` can be unpickled, and `ThreadBusyError` keeps its
message instead of repeating it.
- `repr(MCodeOptions(...))` no longer prints `env` values, which could expose secrets in logs.
- Cancellation sends SIGTERM instead of SIGINT. After Ctrl+C in a terminal had already delivered
SIGINT to the CLI, the SDK's second SIGINT hit the CLI's default action and killed it before it
could persist the Turn and write `exec.completed`.
- Cancellation waits for the CLI's 60 second shutdown budget before SIGKILL (was 10 seconds), and
the `timeout_ms` backstop fires 90 seconds after `timeout_ms` (was 30), matching the
TypeScript SDK. The shorter values could kill a CLI that was still shutting down cleanly.
- `exec.completed` ends the Turn: later output is ignored and a CLI that does not exit within
5 seconds is killed. A background process that inherited the CLI's stdout or stderr no longer
keeps `thread.run()` waiting until it exits; after the CLI exits, remaining output is read for
at most 2 seconds.
- A Thread pins `ThreadOptions.cwd` (default: the current directory) when it is created. A later
`os.chdir()` used to move the next Turn to another workspace, which the CLI rejects for an
existing Session.
- Windows: `mcode.cmd` launchers from the official installer and npm / pnpm are no longer run
through cmd.exe. The SDK runs the JS entry and Node runtime behind them, so arguments are not
reinterpreted by cmd.exe and cancelling stops the CLI itself rather than only cmd.exe.
`MCODE_BIN` and `PATH` are looked up case-insensitively on Windows.

### Documentation

Expand Down
26 changes: 18 additions & 8 deletions packages/code-sdk-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ The SDK is in beta, so installers need `--pre` (or an exact version such as
`minimax-code-sdk==0.1.0b1`) until `0.1.0` is released. Import it as `minimax_code`.

The CLI is found from `MCodeOptions(bin=...)`, then the `MCODE_BIN` environment variable,
then `PATH`.
then `PATH`. The SDK never starts it through a shell. On Windows, the `mcode.cmd` launcher
written by the official installer or by npm / pnpm is followed to the JS entry and Node runtime
it runs (the installer's release pointer is read again before every Turn, so a CLI self-update
is picked up); an unrecognized `.cmd` file is rejected, and you can pass the JS entry as `bin`.

## Quick start

Expand Down Expand Up @@ -51,7 +54,8 @@ asyncio.run(main())
### Resuming a Session

A Session belongs to the workspace it was created in. Pass the same `cwd` to `resume_thread()`;
when `cwd` is omitted, the current working directory is used. A different workspace fails with
when `cwd` is omitted, the current working directory at that call is used. A Thread pins its
`cwd` when it is created, so a later `os.chdir()` does not affect it. A different workspace fails with
`MCodeRunError` ("Session ... belongs to workspace ..."), and retrying does not help.

```python
Expand All @@ -78,13 +82,19 @@ event; iteration raises only for process or protocol failures.

### Cancellation

- `streamed.cancel()` sends SIGINT; the stream ends with the CLI's final events.
- `streamed.cancel()` sends SIGTERM, the CLI's cancel signal; the stream ends with the CLI's
final events. SIGTERM is used instead of SIGINT because Ctrl+C in a terminal already sends
SIGINT to the CLI, and a second SIGINT would skip its graceful shutdown.
- Leaving the `async for` early (`break` or an exception in the loop body), or cancelling the
task running `thread.run()`, stops the CLI (SIGINT, then SIGKILL after a grace period). The
next Turn on the same Thread waits for that shutdown instead of raising `ThreadBusyError`;
`async with streamed:` or `await streamed.aclose()` stops it deterministically.
- `RunOptions(timeout_ms=...)` is enforced by the CLI. If the CLI is still running 30 seconds
after `timeout_ms`, the SDK kills it and raises `MCodeRunError` with `status="timeout"`.
task running `thread.run()`, stops the CLI: SIGTERM, then SIGKILL if it is still running after
the CLI's 60 second shutdown budget. The next Turn on the same Thread waits for that shutdown
instead of raising `ThreadBusyError`; `async with streamed:` or `await streamed.aclose()`
stops it deterministically. On Windows, cancellation stops the CLI immediately.
- `RunOptions(timeout_ms=...)` is enforced by the CLI. If the CLI is still running 90 seconds
after `timeout_ms` (its shutdown budget plus startup time), the SDK kills it and raises
`MCodeRunError` with `status="timeout"`.
- `exec.completed` ends the Turn. A CLI that keeps running afterwards is stopped after 5 seconds,
and a background process that inherited the CLI's output does not keep the Turn open.

### Blocking API

Expand Down
5 changes: 5 additions & 0 deletions packages/code-sdk-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ keywords = ["minimax", "minimax-code", "mcode", "agent", "coding-agent", "sdk"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Operating System :: OS Independent",
"Typing :: Typed",
]
Expand Down
138 changes: 128 additions & 10 deletions packages/code-sdk-python/src/minimax_code/_cli.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,165 @@
"""Locates and version-checks the ``mcode`` CLI."""
"""Locates and version-checks the ``mcode`` CLI. Mirrors ``packages/code-sdk/src/cli-resolver.ts``
and the Windows launcher handling of ``project-bin.ts``: the SDK never starts the CLI through a
shell, so Windows ``.cmd`` launchers are followed to the JS entry and Node runtime they run.
"""

from __future__ import annotations

import asyncio
import os
import re
import shutil
import sys
from collections.abc import Mapping
from dataclasses import dataclass

from .errors import MCODE_INSTALL_HINT, MCodeNotFoundError, MCodeVersionError

MIN_CLI_VERSION = (0, 6, 3)
_JS_SUFFIXES = (".js", ".mjs", ".cjs")
#: Windows files that CreateProcess runs directly, without cmd.exe.
_WINDOWS_NATIVE = (".exe", ".com")
#: Lines of the Windows root launcher written by the official installer and CLI self-update.
_VERSIONED_POINTER_LINE = 'SET /P MCODE_RELEASE=<"%~dp0current"'
_VERSIONED_CALL_LINE = 'CALL "%~dp0releases\\%MCODE_RELEASE%\\.mcode-launcher.cmd"'
#: Same release-key rule the root launcher enforces before following the pointer.
_RELEASE_KEY = re.compile(r"[0-9A-Za-z][0-9A-Za-z._-]*")
#: JS entry in an npm / pnpm ``.cmd`` shim or an installer release launcher.
_CMD_SHIM_ENTRY = re.compile(r'"%~?dp0%?\\?([^"%]+\.(?:c|m)?js)"', re.IGNORECASE)
#: JS entry in an npm / pnpm POSIX shell or PowerShell shim.
_SHELL_SHIM_ENTRY = re.compile(r'"\$basedir[\\/]([^"$]+\.(?:c|m)?js)"')
#: The pinned runtime of an installer release launcher: ``"<node>" "%~dp0<entry>" %*``.
_CMD_RUNTIME = re.compile(r'^"([^"%]+)"[ \t]+"%~dp0', re.MULTILINE)
_SHIM_HEAD_BYTES = 4096


@dataclass(frozen=True)
class ResolvedCli:
"""How to start the CLI."""

path: str
"""The executable, or the JS entry that runs with Node."""
node: str | None = None
"""Node runtime for a JS entry; ``None`` uses ``node`` from ``PATH``."""
launcher: str | None = None
"""Windows installer root launcher. Its release pointer is followed again before every run, so
a CLI self-update between Turns is picked up."""

def command(self, args: list[str]) -> list[str]:
if self.path.endswith(_JS_SUFFIXES):
node = shutil.which("node") or "node"
return [node, self.path, *args]
if self.launcher is not None:
current = _follow_versioned_launcher(self.launcher)
if current is None:
raise MCodeNotFoundError(
f"{self.launcher} no longer points to a usable release. "
"Pass the CLI's JS entry as MCodeOptions(bin=...)."
)
return current.command(args)
if self.path.lower().endswith(_JS_SUFFIXES):
return [self.node or shutil.which("node") or "node", self.path, *args]
return [self.path, *args]


def resolve_cli(bin: str | None, env: Mapping[str, str]) -> ResolvedCli:
def resolve_cli(
bin: str | None, env: Mapping[str, str], platform: str = sys.platform
) -> ResolvedCli:
"""Explicit ``bin`` → ``$MCODE_BIN`` → ``mcode`` on ``PATH``."""
explicit = bin or env.get("MCODE_BIN") or None
explicit = bin or _env(env, "MCODE_BIN", platform) or None
if explicit:
path = os.path.abspath(os.path.expanduser(explicit))
if not os.path.isfile(path):
source = "bin" if bin else "MCODE_BIN"
raise MCodeNotFoundError(f"mcode CLI from {source} does not exist: {path}")
return ResolvedCli(path)
found = shutil.which("mcode", path=env.get("PATH", os.defpath))
return _from_executable(path, platform)
found = shutil.which("mcode", path=_env(env, "PATH", platform) or os.defpath)
if not found:
raise MCodeNotFoundError(
"Could not find the mcode CLI on PATH. Install it with "
f"`{MCODE_INSTALL_HINT}`, set MCODE_BIN, or pass MCodeOptions(bin=...)."
)
return ResolvedCli(found)
return _from_executable(found, platform)


def _from_executable(path: str, platform: str) -> ResolvedCli:
extension = os.path.splitext(path)[1].lower()
if extension in _JS_SUFFIXES or platform != "win32" or extension in _WINDOWS_NATIVE:
return ResolvedCli(path)
# A batch file or shell shim would need cmd.exe and its quoting rules, and cancelling it would
# stop only cmd.exe and leave the CLI running. Run the JS entry behind it instead.
try:
head = _read_head(path)
except OSError:
head = ""
if extension in (".cmd", ".bat"):
resolved = resolve_cmd_launcher(head, path)
else:
entry = _parse_shim(_SHELL_SHIM_ENTRY, head, path)
resolved = ResolvedCli(entry, node=_adjacent_node(path)) if entry else None
if resolved is None:
raise MCodeNotFoundError(
f"Found {path}, but could not determine the JS entry it runs. "
"Pass the CLI's JS entry as MCodeOptions(bin=...) or MCODE_BIN."
)
return resolved


def resolve_cmd_launcher(content: str, launcher: str) -> ResolvedCli | None:
"""Resolves a Windows ``.cmd`` launcher: an npm / pnpm shim, or the installer's root
launcher, whose release is looked up again before every run."""
entry = _parse_shim(_CMD_SHIM_ENTRY, content, launcher)
if entry:
return ResolvedCli(entry, node=_adjacent_node(launcher))
versioned = _VERSIONED_POINTER_LINE in content and _VERSIONED_CALL_LINE in content
if versioned and _follow_versioned_launcher(launcher) is not None:
return ResolvedCli(launcher, launcher=launcher)
return None


def _follow_versioned_launcher(launcher: str) -> ResolvedCli | None:
"""``<prefix>\\mcode.cmd`` reads the release key from ``<prefix>\\current`` and calls
``<prefix>\\releases\\<release>\\.mcode-launcher.cmd``, which runs a pinned Node runtime."""
prefix = os.path.dirname(launcher)
try:
with open(os.path.join(prefix, "current"), encoding="utf-8") as handle:
release = handle.read().strip()
if not _RELEASE_KEY.fullmatch(release):
return None
release_launcher = os.path.join(prefix, "releases", release, ".mcode-launcher.cmd")
content = _read_head(release_launcher)
except OSError:
return None
entry = _parse_shim(_CMD_SHIM_ENTRY, content, release_launcher)
if entry is None:
return None
runtime = _CMD_RUNTIME.search(content)
node = runtime.group(1) if runtime and os.path.isfile(runtime.group(1)) else None
return ResolvedCli(entry, node=node)


def _parse_shim(pattern: re.Pattern[str], content: str, shim: str) -> str | None:
match = pattern.search(content)
if not match:
return None
parts = [part for part in re.split(r"[\\/]", match.group(1)) if part]
entry = os.path.normpath(os.path.join(os.path.dirname(shim), *parts))
return entry if os.path.isfile(entry) else None


def _adjacent_node(shim: str) -> str | None:
"""npm shims prefer a ``node.exe`` next to them over ``PATH``."""
candidate = os.path.join(os.path.dirname(shim), "node.exe")
return candidate if os.path.isfile(candidate) else None


def _read_head(path: str) -> str:
with open(path, "rb") as handle:
return handle.read(_SHIM_HEAD_BYTES).decode("utf-8", "replace")


def _env(env: Mapping[str, str], key: str, platform: str) -> str | None:
"""Environment names are case-insensitive on Windows (``Path``)."""
if platform != "win32":
return env.get(key)
return next((value for name, value in env.items() if name.upper() == key), None)


def parse_version(text: str) -> tuple[int, int, int] | None:
Expand Down Expand Up @@ -84,4 +202,4 @@ async def check_cli_version(cli: ResolvedCli, env: Mapping[str, str]) -> str:
return ".".join(map(str, version))


__all__ = ["ResolvedCli", "check_cli_version", "resolve_cli"]
__all__ = ["ResolvedCli", "check_cli_version", "resolve_cli", "resolve_cmd_launcher"]
20 changes: 15 additions & 5 deletions packages/code-sdk-python/src/minimax_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import dataclasses
import os

from ._args import validate_thread_options
Expand All @@ -27,23 +28,27 @@ def __init__(self, options: MCodeOptions | None = None) -> None:
)

def start_thread(self, options: ThreadOptions | None = None) -> Thread:
"""Starts a new Thread. The Session is created by its first Turn."""
"""Starts a new Thread. The Session is created by its first Turn.

``ThreadOptions.cwd`` defaults to the current working directory and is resolved now, so a
later ``os.chdir()`` does not move the Thread to another workspace.
"""
options = options or ThreadOptions()
validate_thread_options(options, resuming=False)
return Thread(self._host, options, None)
return Thread(self._host, _with_absolute_cwd(options), None)

def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> Thread:
"""Continues an existing Session, for example one persisted from ``thread.id``.

Pass the same ``ThreadOptions.cwd`` the Session was created with. When ``cwd`` is omitted
the current working directory is used, and the CLI rejects a Session whose workspace
differs (``MCodeRunError`` with "belongs to workspace ...").
the current working directory at this call is used, and the CLI rejects a Session whose
workspace differs (``MCodeRunError`` with "belongs to workspace ...").
"""
if not isinstance(thread_id, str) or not thread_id.strip():
raise MCodeInvocationError("resume_thread requires a non-empty Session id.")
options = options or ThreadOptions()
validate_thread_options(options, resuming=True)
return Thread(self._host, options, thread_id.strip())
return Thread(self._host, _with_absolute_cwd(options), thread_id.strip())

def _child_env(self) -> dict[str, str]:
env = dict(self._options.env if self._options.env is not None else os.environ)
Expand All @@ -64,3 +69,8 @@ async def _prepare_cli(self) -> ResolvedCli:
# Only cached on success, so a later run works after installing the CLI.
self._cli = cli
return self._cli


def _with_absolute_cwd(options: ThreadOptions) -> ThreadOptions:
"""Pins the workspace when the Thread is created; every Turn of a Session must use it."""
return dataclasses.replace(options, cwd=os.path.abspath(options.cwd or os.getcwd()))
Loading
Loading