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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ jobs:
- name: Test Python OAuth example
run: uv run --isolated --project examples/mcp-oauth-client/python pytest examples/mcp-oauth-client/python

- name: Test Python batch runner
run: uv run --isolated --project examples/python-batch-runner pytest examples/python-batch-runner

- name: Validate package contents
run: pnpm pack:dry-run

Expand Down Expand Up @@ -71,3 +74,11 @@ jobs:

- name: Check integration versions
run: pnpm check:versions

- name: Set up uv
uses: astral-sh/setup-uv@v10.0.1
with:
python-version: "3.12"

- name: Test Python command launching on Windows
run: uv run --isolated --project examples/python-batch-runner pytest examples/python-batch-runner/test_command.py
2 changes: 2 additions & 0 deletions docs/install/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,5 @@ for the canonical command and option reference.

When embedding the CLI in a Node application on Windows, follow the
[shell-free child-process guidance](./troubleshooting.md#run-call-e-from-node-on-windows).
For Python, see [Windows subprocess guidance](./troubleshooting.md#run-call-e-from-python-on-windows),
including how to avoid `FileNotFoundError: [WinError 2]`.
75 changes: 75 additions & 0 deletions docs/install/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,81 @@ These steps were tested on macOS with Cursor 3.21.16, Node 26.8.2, and CALL-E CL
`NODE_USE_ENV_PROXY=1` also resolved the CLI's `fetch failed`. This check does not
cover a fresh OAuth login or skill installation.

## Run CALL-E from Python on Windows

For Python applications, invoke the existing CLI launcher with a trusted Node
executable and pass arguments as JSON through stdin. This avoids Windows
command-name lookup and shell parsing of argument values. The launcher verifies
that the selected package is `@call-e/cli` before running it.

### Recommended invocation

Follow [CLI entry point selection](../../packages/cli/docs/cli-reference.md#selecting-the-cli-entry-point)
to select a trusted installation. Replace the two paths below with your absolute
Node executable and `@call-e/cli` package paths:

```python
import json
import subprocess
from pathlib import Path

node = Path(r"C:\Program Files\nodejs\node.exe")
package_dir = Path(r"C:\trusted\node_modules\@call-e\cli")
launcher = package_dir / "scripts" / "run-agent-command.mjs"
request = {"package_dir": str(package_dir), "argv": ["--help"]}

subprocess.run(
[str(node), str(launcher)],
input=json.dumps(request),
text=True,
shell=False,
check=True,
)
```

This help check requires no login and makes no calls. Use the same invocation
on macOS or Linux with the corresponding absolute paths. For other commands,
change `argv`; see the CLI reference for integration attribution and returned
argument arrays.

### Common questions

**Why does `calle` work in my terminal but fail from Python?**

Python's `subprocess.run(["calle", "--help"])` can raise
`FileNotFoundError: [WinError 2]` on Windows. npm provides a `calle.cmd` wrapper,
while a direct process launch does not resolve the bare name like a shell does.
The CLI has not started when this error occurs.

**Can I use `calle.cmd` or `cmd /c` instead?**

The reported Windows test confirmed that `calle.cmd --help` displayed help when
called from Python, and the reporter used `cmd /c` for a status query. Those
results do not verify arbitrary arguments. For application integrations, use
the recommended launcher: command scripts can reinterpret quotes, special
characters, and multiline text. Adding `shell=True` is not needed.

**Where do I find the paths?**

In PowerShell, `Get-Command node.exe` shows the Node executable. For a global
npm installation, `npm root -g` gives the package root; append `@call-e/cli` to
that directory. For a local installation, select the trusted application's
`node_modules/@call-e/cli` directory. Configure these paths once instead of
running discovery commands before every invocation.

**How do I pass spaces, quotes, or multiline text?**

Keep each argument as one string in `argv`, including an entire multiline value
as one element. Let `json.dumps` serialize the request. Do not add shell quotes
to individual arguments, concatenate a command string, or use `shell=True`.

**What if the launcher rejects the package or entry point?**

Check that `package_dir` names the `@call-e/cli` directory, not its parent
`node_modules` directory or the older `@call-e/calle` SDK. If the launcher reports
that command-help checks failed, update the trusted CLI installation as directed
by the CLI reference before authenticating.

## Run CALL-E from Node on Windows

### Symptoms
Expand Down
10 changes: 10 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,13 @@ not define a supported application API.
The default e2e tests use a local fake broker/OAuth/MCP server, so they do not
require real CALL-E credentials or browser login. Live verification against the
real CALL-E service is opt-in through each example README.

## Invocation conventions

The OAuth and broker examples connect directly to MCP; they do not require a
CLI subprocess. The batch runner reuses the CLI login state before making MCP
calls. For applications that invoke CLI commands, follow the shared
[Python/Windows invocation guide](../docs/install/troubleshooting.md#run-call-e-from-python-on-windows)
and [CLI reference](../packages/cli/docs/cli-reference.md#selecting-the-cli-entry-point)
instead of constructing shell command strings. Keep platform troubleshooting
in the guide and example-specific options in each example README.
15 changes: 15 additions & 0 deletions examples/python-batch-runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,21 @@ The script runs these prechecks before processing the JSONL file:
4. If the CLI is not logged in, pause and ask the operator to run
`calle auth login`, then press Enter to continue.

## CLI invocation

The runner uses the same executable lookup for its CLI precheck, authentication
check, and npm installation. Bare names such as `calle` use PATH (including the
Windows `calle.cmd` wrapper); explicit paths such as `./calle` or an absolute
path keep their meaning. An existing file must also be executable.

`--calle-command` accepts an executable path or a command with arguments. Use
double quotes around paths containing spaces when combining them with arguments.
The runner passes arguments separately without enabling a shell. Its CLI calls
only check authentication; call payloads go directly through MCP.

For general Python-to-CLI integrations and Windows troubleshooting, follow the
[shared CLI invocation guidance](../../docs/install/troubleshooting.md#run-call-e-from-python-on-windows).

Input is JSONL. Each line may use the customer payload shape directly:

```json
Expand Down
39 changes: 29 additions & 10 deletions examples/python-batch-runner/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import asyncio
import hashlib
import json
import os
import re
import shlex
import shutil
Expand Down Expand Up @@ -129,6 +130,21 @@ def parse_positive_float(value: str) -> float:
return parsed


def parse_cli_command(value: str) -> list[str]:
# An executable path may contain spaces without containing CLI arguments.
if Path(value).expanduser().is_file():
return [value]
if sys.platform == "win32":
# Preserve Windows path separators; quotes group paths containing spaces.
parts = shlex.split(value, posix=False)
parts = [part[1:-1] if part.startswith('"') and part.endswith('"') else part for part in parts]
else:
parts = shlex.split(value)
if not parts:
raise argparse.ArgumentTypeError("expected a CLI command or executable path")
return parts


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run CALL-E MCP calls from a JSONL batch file.")
parser.add_argument("--input", required=True, type=Path, help="Path to the input JSONL file.")
Expand All @@ -142,7 +158,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--channel", default=DEFAULT_CHANNEL, help=f"MCP channel. Default: {DEFAULT_CHANNEL}.")
parser.add_argument("--server-url", help="Full MCP server URL. Overrides --base-url and --channel.")
parser.add_argument("--cache-root", default=DEFAULT_CACHE_ROOT, help=f"calle CLI cache root. Default: {DEFAULT_CACHE_ROOT}.")
parser.add_argument("--calle-command", default="calle", help="calle CLI command or path. Default: calle.")
parser.add_argument("--calle-command", type=parse_cli_command, default="calle", help="calle CLI command or path. Default: calle.")
parser.add_argument("--npm-command", default="npm", help="npm command used for automatic CLI installation. Default: npm.")
parser.add_argument("--cli-package", default=DEFAULT_CLI_PACKAGE, help=f"CLI package to install when calle is missing. Default: {DEFAULT_CLI_PACKAGE}.")
parser.add_argument("--no-auto-install-cli", action="store_true", help="Fail if calle is missing instead of installing it.")
Expand All @@ -168,7 +184,7 @@ def read_config(argv: list[str] | None = None) -> Config:
channel=args.channel,
server_url=resolve_server_url(args.base_url, args.channel, args.server_url),
cache_root=args.cache_root,
calle_command=shlex.split(args.calle_command),
calle_command=args.calle_command,
npm_command=args.npm_command,
cli_package=args.cli_package,
auto_install_cli=not args.no_auto_install_cli,
Expand All @@ -180,26 +196,29 @@ def read_config(argv: list[str] | None = None) -> Config:
)


def resolve_executable(executable: str) -> str | None:
# Keep explicit relative paths (./tool) distinct from PATH command names.
return shutil.which(os.path.expanduser(executable))


def run_command(command: list[str], *, capture: bool = True) -> subprocess.CompletedProcess[str]:
resolved = resolve_executable(command[0])
if resolved is None:
raise CliUnavailableError(f"Executable not found: {command[0]}")
return subprocess.run(
command,
[resolved, *command[1:]],
check=False,
text=True,
capture_output=capture,
)


def executable_exists(command: list[str]) -> bool:
if not command:
return False
executable = command[0]
if Path(executable).expanduser().exists():
return True
return shutil.which(executable) is not None
return bool(command) and resolve_executable(command[0]) is not None


def install_calle_cli(config: Config, console: Console) -> None:
npm_path = shutil.which(config.npm_command)
npm_path = resolve_executable(config.npm_command)
if not npm_path:
raise CliUnavailableError(
f"`{config.calle_command[0]}` is not installed and `{config.npm_command}` was not found. "
Expand Down
98 changes: 98 additions & 0 deletions examples/python-batch-runner/test_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import json
import os
import sys
from unittest.mock import patch

import pytest

import client


def test_windows_command_paths_preserve_backslashes():
with patch.object(client.sys, "platform", "win32"):
assert client.parse_cli_command(r"C:\tools\calle.cmd") == [r"C:\tools\calle.cmd"]
assert client.parse_cli_command(r'"C:\Program Files\nodejs\node.exe" "C:\tools\calle.js"') == [
r"C:\Program Files\nodejs\node.exe", r"C:\tools\calle.js"
]


def test_existing_executable_path_with_spaces(tmp_path):
executable = tmp_path / "my cli"
executable.touch()
assert client.parse_cli_command(str(executable)) == [str(executable)]


def test_launch_uses_resolved_command_and_preserves_arguments():
with patch.object(client.shutil, "which", return_value=r"C:\npm\calle.cmd"), patch.object(client.subprocess, "run") as run:
assert client.executable_exists(["calle"])
client.run_command(["calle", "auth", "status", "--cache-root", "path with spaces"])
assert run.call_args.args[0] == [r"C:\npm\calle.cmd", "auth", "status", "--cache-root", "path with spaces"]
assert run.call_args.kwargs.get("shell", False) is False


def test_missing_command_has_actionable_error():
with patch.object(client.shutil, "which", return_value=None):
with pytest.raises(client.CliUnavailableError, match="Executable not found"):
client.run_command(["missing-calle", "--help"])


@pytest.mark.skipif(sys.platform != "win32", reason="Windows npm shim lookup")
def test_windows_cmd_lookup_and_launch(tmp_path, monkeypatch):
shim = tmp_path / "calle.cmd"
shim.write_text('@echo off\necho {"usable":true}\n')
monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"])
assert client.executable_exists(["calle"])
result = client.run_command(["calle", "auth", "status", "--json"])
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {"usable": True}


def test_documented_python_launcher():
import re
import shutil
import subprocess
from pathlib import Path

root = Path(__file__).resolve().parents[2]
section = (root / "docs/install/troubleshooting.md").read_text().split(
"## Run CALL-E from Python on Windows", 1
)[1].split("## Run CALL-E from Node on Windows", 1)[0]
code = re.search(r"```python\n(.*?)```", section, re.S).group(1)
code = code.replace(r"C:\Program Files\nodejs\node.exe", shutil.which("node"))
code = code.replace(r"C:\trusted\node_modules\@call-e\cli", str(root / "packages/cli"))
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert "Usage: calle" in result.stdout


def test_explicit_relative_path_does_not_select_path_namesake(tmp_path, monkeypatch):
name = "local cli.cmd" if sys.platform == "win32" else "local cli"
local = tmp_path / name
shadow_dir = tmp_path / "path-bin"
shadow_dir.mkdir()
for path, output in [(local, "local"), (shadow_dir / name, "shadow")]:
prefix = "@echo off\n" if sys.platform == "win32" else "#!/bin/sh\n"
path.write_text(prefix + f"echo {output}\n")
path.chmod(0o755)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PATH", str(shadow_dir) + os.pathsep + os.environ["PATH"])
explicit = "./" + name
for value in [explicit, f'"{explicit}" --help']:
command = client.parse_cli_command(value)
assert client.executable_exists(command)
result = client.run_command(command)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "local"
# Windows may also search the current directory for a bare command.
if sys.platform != "win32":
assert client.run_command([name]).stdout.strip() == "shadow"


def test_non_executable_file_fails_precheck_on_posix(tmp_path):
if sys.platform == "win32":
pytest.skip("POSIX executable permission")
path = tmp_path / "not-executable"
path.touch(mode=0o600)
assert not client.executable_exists([str(path)])
with pytest.raises(client.CliUnavailableError, match="Executable not found"):
client.run_command([str(path)])
Loading