pyclaudecli 1.0.2__tar.gz → 1.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/PKG-INFO +59 -2
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/README.md +54 -1
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/pyproject.toml +5 -1
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/src/pyclaudecli/__init__.py +1 -1
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/src/pyclaudecli/__main__.py +3 -1
- pyclaudecli-1.0.4/src/pyclaudecli/_process.py +379 -0
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/src/pyclaudecli/client.py +37 -12
- pyclaudecli-1.0.4/tests/test_portability.py +206 -0
- pyclaudecli-1.0.4/tests/test_security.py +159 -0
- pyclaudecli-1.0.4/uv.lock +8 -0
- pyclaudecli-1.0.2/src/pyclaudecli/_process.py +0 -241
- pyclaudecli-1.0.2/tests/test_usage.py +0 -28
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/.gitignore +0 -0
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/LICENSE +0 -0
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.4}/src/pyclaudecli/exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyclaudecli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more
|
|
5
5
|
Author-email: Suriya Ravichandran <suriyaravichandran@itrendsolution.com>
|
|
6
6
|
License: MIT
|
|
@@ -9,7 +9,10 @@ Keywords: anthropic,automation,claude,claude-code,cli,sdk,wrapper
|
|
|
9
9
|
Classifier: Development Status :: 3 - Alpha
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
12
14
|
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
16
|
Classifier: Programming Language :: Python :: 3
|
|
14
17
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
18
|
Classifier: Programming Language :: Python :: 3.8
|
|
@@ -17,6 +20,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
17
20
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
21
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
22
|
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
24
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
25
|
Requires-Python: >=3.8
|
|
22
26
|
Description-Content-Type: text/markdown
|
|
@@ -41,6 +45,7 @@ It's a thin wrapper, not a reimplementation — every call runs the real `claude
|
|
|
41
45
|
- [Command-line usage](#command-line-usage)
|
|
42
46
|
- [Errors](#errors)
|
|
43
47
|
- [`build_flags`](#build_flags)
|
|
48
|
+
- [Security notes](#security-notes)
|
|
44
49
|
- [Every method, with an example](#every-method-with-an-example)
|
|
45
50
|
- [Development](#development)
|
|
46
51
|
- [License](#license)
|
|
@@ -53,6 +58,10 @@ pip install pyclaudecli
|
|
|
53
58
|
|
|
54
59
|
Requires the `claude` CLI itself to be installed and on `PATH` (see the [Claude Code docs](https://claude.com/claude-code)).
|
|
55
60
|
|
|
61
|
+
Runs on Linux, macOS and Windows, Python 3.8+. On Windows the CLI is installed as
|
|
62
|
+
`claude.cmd`, which `CreateProcess` won't find from a bare `"claude"` — the wrapper
|
|
63
|
+
resolves it through `PATHEXT` for you, so the default `ClaudeCLI()` works there too.
|
|
64
|
+
|
|
56
65
|
## Quickstart
|
|
57
66
|
|
|
58
67
|
```python
|
|
@@ -81,6 +90,15 @@ claude = ClaudeCLI(
|
|
|
81
90
|
)
|
|
82
91
|
```
|
|
83
92
|
|
|
93
|
+
`env` is merged **over** the current environment, so `PATH`, `HOME` and the rest still
|
|
94
|
+
reach the CLI — passing one API key doesn't cost you the ability to find the binary or
|
|
95
|
+
its credentials. Pass `replace_env=True` if you really want the child to start from a
|
|
96
|
+
clean slate:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
|
|
100
|
+
```
|
|
101
|
+
|
|
84
102
|
## Authentication
|
|
85
103
|
|
|
86
104
|
`claude` handles auth itself, so `pyclaudecli` just drives it. You have two options.
|
|
@@ -410,13 +428,52 @@ gateway.terminate()
|
|
|
410
428
|
|
|
411
429
|
See `ClaudeCLI`'s docstrings for the exact CLI flags behind each method — it covers every top-level `claude` command (`auth`, `mcp`, `plugin`, `project`, `agents`/background sessions, `auto-mode`, `doctor`, `update`, `install`, `import`, `ultrareview`, `gateway`) plus the main prompt flags. Anything not exposed as a named parameter can still be passed through via each method's `extra_flags` dict.
|
|
412
430
|
|
|
431
|
+
## Security notes
|
|
432
|
+
|
|
433
|
+
A few things the wrapper does on your behalf, worth knowing if you're feeding it input
|
|
434
|
+
from anywhere but your own code:
|
|
435
|
+
|
|
436
|
+
**Prompts can't smuggle in CLI flags.** `prompt()`, `prompt_stream()`, `start_background()`
|
|
437
|
+
and the `pyclaudecli` command put `--` between the options and your text, so a prompt that
|
|
438
|
+
starts with a dash is text, not a flag. Without it, user-supplied input like
|
|
439
|
+
`--dangerously-skip-permissions` or `--settings /tmp/evil.json` would be parsed by the CLI:
|
|
440
|
+
|
|
441
|
+
```python
|
|
442
|
+
claude.prompt("--version") # asks Claude about "--version"; does not run the flag
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Flags you actually want still go through named parameters or `extra_flags`. Note this
|
|
446
|
+
protection covers the prompt text — if you interpolate untrusted input into a *flag value*
|
|
447
|
+
(`model=`, `settings=`, `add_dir=`), validate it yourself.
|
|
448
|
+
|
|
449
|
+
**Errors don't spill credentials.** `ClaudeCLIError.args`/`.cmd` and the exception message
|
|
450
|
+
are redacted before they're raised, so MCP auth headers, injected `--env` values, tokens
|
|
451
|
+
inside an `mcp add-json` payload and anything shaped like `sk-ant-…`, `Bearer …` or
|
|
452
|
+
`password=…` come back as `<redacted>`. Flag names survive so the command is still
|
|
453
|
+
recognisable, and long values are truncated. Tracebacks and log aggregators get the
|
|
454
|
+
redacted form; `exc.stdout`/`exc.stderr` still hold the raw output for local debugging.
|
|
455
|
+
|
|
456
|
+
**Login codes are handled as single-use secrets.** `auth_login()` writes exactly one line
|
|
457
|
+
to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
|
|
458
|
+
code and the captured output from memory once it's submitted. The child process and its
|
|
459
|
+
pipes are always cleaned up, including when your `code_provider` raises.
|
|
460
|
+
|
|
461
|
+
Two things it deliberately does *not* do: it never runs a shell (every call is an argv
|
|
462
|
+
list, so there's no shell-injection surface), and it doesn't manage credentials itself —
|
|
463
|
+
auth lives with the `claude` CLI and your environment. Remember the argv of a running
|
|
464
|
+
process is visible to other users on the same machine via `ps`, so prefer env vars over
|
|
465
|
+
flags for anything sensitive.
|
|
466
|
+
|
|
467
|
+
Every call is an argv list on every platform — no shell, and no `shell=True` fallback on
|
|
468
|
+
Windows, so quoting rules can't be turned into an injection.
|
|
469
|
+
|
|
413
470
|
## Development
|
|
414
471
|
|
|
415
472
|
```bash
|
|
416
473
|
git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
|
|
417
474
|
cd pyclaudecli
|
|
418
475
|
pip install -e .
|
|
419
|
-
pytest
|
|
476
|
+
pytest # offline; does not invoke the claude binary
|
|
420
477
|
```
|
|
421
478
|
|
|
422
479
|
## License
|
|
@@ -18,6 +18,7 @@ It's a thin wrapper, not a reimplementation — every call runs the real `claude
|
|
|
18
18
|
- [Command-line usage](#command-line-usage)
|
|
19
19
|
- [Errors](#errors)
|
|
20
20
|
- [`build_flags`](#build_flags)
|
|
21
|
+
- [Security notes](#security-notes)
|
|
21
22
|
- [Every method, with an example](#every-method-with-an-example)
|
|
22
23
|
- [Development](#development)
|
|
23
24
|
- [License](#license)
|
|
@@ -30,6 +31,10 @@ pip install pyclaudecli
|
|
|
30
31
|
|
|
31
32
|
Requires the `claude` CLI itself to be installed and on `PATH` (see the [Claude Code docs](https://claude.com/claude-code)).
|
|
32
33
|
|
|
34
|
+
Runs on Linux, macOS and Windows, Python 3.8+. On Windows the CLI is installed as
|
|
35
|
+
`claude.cmd`, which `CreateProcess` won't find from a bare `"claude"` — the wrapper
|
|
36
|
+
resolves it through `PATHEXT` for you, so the default `ClaudeCLI()` works there too.
|
|
37
|
+
|
|
33
38
|
## Quickstart
|
|
34
39
|
|
|
35
40
|
```python
|
|
@@ -58,6 +63,15 @@ claude = ClaudeCLI(
|
|
|
58
63
|
)
|
|
59
64
|
```
|
|
60
65
|
|
|
66
|
+
`env` is merged **over** the current environment, so `PATH`, `HOME` and the rest still
|
|
67
|
+
reach the CLI — passing one API key doesn't cost you the ability to find the binary or
|
|
68
|
+
its credentials. Pass `replace_env=True` if you really want the child to start from a
|
|
69
|
+
clean slate:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
|
|
73
|
+
```
|
|
74
|
+
|
|
61
75
|
## Authentication
|
|
62
76
|
|
|
63
77
|
`claude` handles auth itself, so `pyclaudecli` just drives it. You have two options.
|
|
@@ -387,13 +401,52 @@ gateway.terminate()
|
|
|
387
401
|
|
|
388
402
|
See `ClaudeCLI`'s docstrings for the exact CLI flags behind each method — it covers every top-level `claude` command (`auth`, `mcp`, `plugin`, `project`, `agents`/background sessions, `auto-mode`, `doctor`, `update`, `install`, `import`, `ultrareview`, `gateway`) plus the main prompt flags. Anything not exposed as a named parameter can still be passed through via each method's `extra_flags` dict.
|
|
389
403
|
|
|
404
|
+
## Security notes
|
|
405
|
+
|
|
406
|
+
A few things the wrapper does on your behalf, worth knowing if you're feeding it input
|
|
407
|
+
from anywhere but your own code:
|
|
408
|
+
|
|
409
|
+
**Prompts can't smuggle in CLI flags.** `prompt()`, `prompt_stream()`, `start_background()`
|
|
410
|
+
and the `pyclaudecli` command put `--` between the options and your text, so a prompt that
|
|
411
|
+
starts with a dash is text, not a flag. Without it, user-supplied input like
|
|
412
|
+
`--dangerously-skip-permissions` or `--settings /tmp/evil.json` would be parsed by the CLI:
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
claude.prompt("--version") # asks Claude about "--version"; does not run the flag
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Flags you actually want still go through named parameters or `extra_flags`. Note this
|
|
419
|
+
protection covers the prompt text — if you interpolate untrusted input into a *flag value*
|
|
420
|
+
(`model=`, `settings=`, `add_dir=`), validate it yourself.
|
|
421
|
+
|
|
422
|
+
**Errors don't spill credentials.** `ClaudeCLIError.args`/`.cmd` and the exception message
|
|
423
|
+
are redacted before they're raised, so MCP auth headers, injected `--env` values, tokens
|
|
424
|
+
inside an `mcp add-json` payload and anything shaped like `sk-ant-…`, `Bearer …` or
|
|
425
|
+
`password=…` come back as `<redacted>`. Flag names survive so the command is still
|
|
426
|
+
recognisable, and long values are truncated. Tracebacks and log aggregators get the
|
|
427
|
+
redacted form; `exc.stdout`/`exc.stderr` still hold the raw output for local debugging.
|
|
428
|
+
|
|
429
|
+
**Login codes are handled as single-use secrets.** `auth_login()` writes exactly one line
|
|
430
|
+
to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
|
|
431
|
+
code and the captured output from memory once it's submitted. The child process and its
|
|
432
|
+
pipes are always cleaned up, including when your `code_provider` raises.
|
|
433
|
+
|
|
434
|
+
Two things it deliberately does *not* do: it never runs a shell (every call is an argv
|
|
435
|
+
list, so there's no shell-injection surface), and it doesn't manage credentials itself —
|
|
436
|
+
auth lives with the `claude` CLI and your environment. Remember the argv of a running
|
|
437
|
+
process is visible to other users on the same machine via `ps`, so prefer env vars over
|
|
438
|
+
flags for anything sensitive.
|
|
439
|
+
|
|
440
|
+
Every call is an argv list on every platform — no shell, and no `shell=True` fallback on
|
|
441
|
+
Windows, so quoting rules can't be turned into an injection.
|
|
442
|
+
|
|
390
443
|
## Development
|
|
391
444
|
|
|
392
445
|
```bash
|
|
393
446
|
git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
|
|
394
447
|
cd pyclaudecli
|
|
395
448
|
pip install -e .
|
|
396
|
-
pytest
|
|
449
|
+
pytest # offline; does not invoke the claude binary
|
|
397
450
|
```
|
|
398
451
|
|
|
399
452
|
## License
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pyclaudecli"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -25,6 +25,10 @@ classifiers = [
|
|
|
25
25
|
"Programming Language :: Python :: 3.10",
|
|
26
26
|
"Programming Language :: Python :: 3.11",
|
|
27
27
|
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Operating System :: POSIX :: Linux",
|
|
30
|
+
"Operating System :: MacOS",
|
|
31
|
+
"Operating System :: Microsoft :: Windows",
|
|
28
32
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
29
33
|
]
|
|
30
34
|
|
|
@@ -45,7 +45,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
45
45
|
model, prompt = parse_args(argv)
|
|
46
46
|
client = ClaudeCLI()
|
|
47
47
|
try:
|
|
48
|
-
|
|
48
|
+
# `--` keeps a prompt that starts with a dash from being parsed as
|
|
49
|
+
# flags by the CLI (this parser passes unknown args through as text).
|
|
50
|
+
result = client.run(["--print", "--model", model, "--", prompt], check=False)
|
|
49
51
|
except Exception as exc: # ClaudeNotFoundError, etc.
|
|
50
52
|
print(str(exc), file=sys.stderr)
|
|
51
53
|
return 1
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Low-level process plumbing shared by ClaudeCLI's methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import queue
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable, Iterator, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
|
|
16
|
+
|
|
17
|
+
_URL_PATTERN = re.compile(r"https://\S+")
|
|
18
|
+
|
|
19
|
+
# Flags whose values are credentials (MCP auth headers, injected env vars).
|
|
20
|
+
_SECRET_FLAGS = frozenset({"--header", "--env", "--client-id", "--client-secret"})
|
|
21
|
+
|
|
22
|
+
# Credential shapes that can turn up anywhere in an argv, including inside the
|
|
23
|
+
# JSON blob passed to `mcp add-json`.
|
|
24
|
+
_SECRET_VALUE_PATTERN = re.compile(
|
|
25
|
+
r"""(
|
|
26
|
+
sk-ant-[\w-]+ # Anthropic API keys
|
|
27
|
+
| pypi-[\w-]+ # PyPI tokens
|
|
28
|
+
| gh[pousr]_[A-Za-z0-9]+ # GitHub tokens
|
|
29
|
+
| [Bb]earer\s+\S+ # bearer tokens
|
|
30
|
+
| (?i:[\w.-]*(?:token|secret|key|password|passwd|auth)[\w.-]*)
|
|
31
|
+
\s*["\s]*[=:]\s*"?[^\s"',}]+ # anything token/secret/key-ish = value
|
|
32
|
+
)""",
|
|
33
|
+
re.VERBOSE,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_REDACTED = "<redacted>"
|
|
37
|
+
_MAX_ARG_LEN = 120
|
|
38
|
+
_MAX_DETAIL_LEN = 2000
|
|
39
|
+
_MAX_LOGIN_BUFFER = 64 * 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def redact_arg(value: str, limit: Optional[int] = _MAX_ARG_LEN) -> str:
|
|
43
|
+
"""Masks credential-looking substrings, and truncates very long values."""
|
|
44
|
+
cleaned = _SECRET_VALUE_PATTERN.sub(_REDACTED, str(value))
|
|
45
|
+
if limit is not None and len(cleaned) > limit:
|
|
46
|
+
cleaned = cleaned[:limit] + "…"
|
|
47
|
+
return cleaned
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_command(command: Sequence[str]) -> list:
|
|
51
|
+
"""Returns a copy of an argv safe to put in an error message or a log.
|
|
52
|
+
|
|
53
|
+
Values of credential-bearing flags are replaced wholesale; everything else
|
|
54
|
+
is scanned for embedded secrets and truncated. Flag *names* survive, so the
|
|
55
|
+
command stays recognisable when debugging.
|
|
56
|
+
"""
|
|
57
|
+
safe = []
|
|
58
|
+
masking = False
|
|
59
|
+
for token in command:
|
|
60
|
+
token = str(token)
|
|
61
|
+
if token.startswith("-"):
|
|
62
|
+
masking = token in _SECRET_FLAGS
|
|
63
|
+
safe.append(token)
|
|
64
|
+
continue
|
|
65
|
+
safe.append(_REDACTED if masking else redact_arg(token))
|
|
66
|
+
return safe
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def format_command(command: Sequence[str]) -> str:
|
|
70
|
+
"""A redacted, shell-ish rendering of an argv for error messages."""
|
|
71
|
+
return " ".join(redact_command(command))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_binary(binary: str, env: Optional[dict] = None) -> str:
|
|
75
|
+
"""Finds the executable to run, the same way a shell would.
|
|
76
|
+
|
|
77
|
+
On Windows the `claude` CLI is an npm shim (`claude.cmd`), and
|
|
78
|
+
CreateProcess only ever appends `.exe` — so a bare "claude" is not found
|
|
79
|
+
unless it is resolved through PATHEXT first. `shutil.which` handles that,
|
|
80
|
+
and on POSIX it is a no-op beyond returning the absolute path.
|
|
81
|
+
|
|
82
|
+
Falls back to the name as given (an explicit path, or something the OS can
|
|
83
|
+
still resolve), so the usual ClaudeNotFoundError is what callers see.
|
|
84
|
+
"""
|
|
85
|
+
if os.path.dirname(binary):
|
|
86
|
+
return binary
|
|
87
|
+
path = (env or os.environ).get("PATH")
|
|
88
|
+
return shutil.which(binary, path=path) or binary
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _PipeReader:
|
|
92
|
+
"""Reads a child's stdout from a thread, so waiting works everywhere.
|
|
93
|
+
|
|
94
|
+
`select` only accepts sockets on Windows, so it cannot be used to poll a
|
|
95
|
+
subprocess pipe. A daemon thread doing blocking reads is portable, and
|
|
96
|
+
still hands back partial output the moment it arrives — which matters for
|
|
97
|
+
a login prompt that never ends in a newline.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
_EOF = object()
|
|
101
|
+
|
|
102
|
+
def __init__(self, fd: int) -> None:
|
|
103
|
+
self._fd = fd
|
|
104
|
+
self._chunks: "queue.Queue" = queue.Queue()
|
|
105
|
+
self._thread = threading.Thread(target=self._pump, daemon=True)
|
|
106
|
+
self._thread.start()
|
|
107
|
+
|
|
108
|
+
def _pump(self) -> None:
|
|
109
|
+
try:
|
|
110
|
+
while True:
|
|
111
|
+
data = os.read(self._fd, 4096)
|
|
112
|
+
if not data:
|
|
113
|
+
break
|
|
114
|
+
self._chunks.put(data)
|
|
115
|
+
except (OSError, ValueError):
|
|
116
|
+
pass # pipe closed underneath us; treated as EOF
|
|
117
|
+
finally:
|
|
118
|
+
self._chunks.put(self._EOF)
|
|
119
|
+
|
|
120
|
+
def read(self, timeout: float) -> Optional[str]:
|
|
121
|
+
"""Returns decoded output, "" if nothing arrived in time, None at EOF."""
|
|
122
|
+
try:
|
|
123
|
+
item = self._chunks.get(timeout=timeout)
|
|
124
|
+
except queue.Empty:
|
|
125
|
+
return ""
|
|
126
|
+
if item is self._EOF:
|
|
127
|
+
return None
|
|
128
|
+
return item.decode("utf-8", errors="replace")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class CommandResult:
|
|
133
|
+
args: list
|
|
134
|
+
returncode: int
|
|
135
|
+
stdout: str
|
|
136
|
+
stderr: str
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def ok(self) -> bool:
|
|
140
|
+
return self.returncode == 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run(
|
|
144
|
+
binary: str,
|
|
145
|
+
args: Sequence[str],
|
|
146
|
+
*,
|
|
147
|
+
cwd: Optional[str] = None,
|
|
148
|
+
env: Optional[dict] = None,
|
|
149
|
+
input_text: Optional[str] = None,
|
|
150
|
+
timeout: Optional[float] = None,
|
|
151
|
+
check: bool = True,
|
|
152
|
+
) -> CommandResult:
|
|
153
|
+
command = [resolve_binary(binary, env), *args]
|
|
154
|
+
try:
|
|
155
|
+
completed = subprocess.run(
|
|
156
|
+
command,
|
|
157
|
+
cwd=cwd,
|
|
158
|
+
env=env,
|
|
159
|
+
input=input_text,
|
|
160
|
+
capture_output=True,
|
|
161
|
+
text=True,
|
|
162
|
+
timeout=timeout,
|
|
163
|
+
)
|
|
164
|
+
except FileNotFoundError as exc:
|
|
165
|
+
raise ClaudeNotFoundError(
|
|
166
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
167
|
+
cmd=redact_command(command),
|
|
168
|
+
) from exc
|
|
169
|
+
except subprocess.TimeoutExpired as exc:
|
|
170
|
+
stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
171
|
+
stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
172
|
+
raise ClaudeTimeoutError(
|
|
173
|
+
f"'{format_command(command)}' did not finish within {timeout}s",
|
|
174
|
+
cmd=redact_command(command),
|
|
175
|
+
stdout=stdout,
|
|
176
|
+
stderr=stderr,
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
result = CommandResult(command, completed.returncode, completed.stdout, completed.stderr)
|
|
180
|
+
if check and not result.ok:
|
|
181
|
+
detail = redact_arg((result.stderr or result.stdout or "").strip(), limit=_MAX_DETAIL_LEN)
|
|
182
|
+
raise ClaudeCLIError(
|
|
183
|
+
f"'{format_command(command)}' exited with {result.returncode}: {detail}",
|
|
184
|
+
returncode=result.returncode,
|
|
185
|
+
stdout=result.stdout,
|
|
186
|
+
stderr=result.stderr,
|
|
187
|
+
cmd=redact_command(command),
|
|
188
|
+
)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def stream_lines(
|
|
193
|
+
binary: str,
|
|
194
|
+
args: Sequence[str],
|
|
195
|
+
*,
|
|
196
|
+
cwd: Optional[str] = None,
|
|
197
|
+
env: Optional[dict] = None,
|
|
198
|
+
) -> Iterator[str]:
|
|
199
|
+
"""Runs a command and yields decoded stdout lines as they arrive."""
|
|
200
|
+
command = [resolve_binary(binary, env), *args]
|
|
201
|
+
try:
|
|
202
|
+
proc = subprocess.Popen(
|
|
203
|
+
command,
|
|
204
|
+
cwd=cwd,
|
|
205
|
+
env=env,
|
|
206
|
+
stdout=subprocess.PIPE,
|
|
207
|
+
stderr=subprocess.STDOUT,
|
|
208
|
+
text=True,
|
|
209
|
+
bufsize=1,
|
|
210
|
+
)
|
|
211
|
+
except FileNotFoundError as exc:
|
|
212
|
+
raise ClaudeNotFoundError(
|
|
213
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
214
|
+
cmd=redact_command(command),
|
|
215
|
+
) from exc
|
|
216
|
+
|
|
217
|
+
assert proc.stdout is not None
|
|
218
|
+
try:
|
|
219
|
+
for line in proc.stdout:
|
|
220
|
+
yield line.rstrip("\n")
|
|
221
|
+
finally:
|
|
222
|
+
proc.stdout.close()
|
|
223
|
+
returncode = proc.wait()
|
|
224
|
+
if returncode != 0:
|
|
225
|
+
raise ClaudeCLIError(
|
|
226
|
+
f"'{format_command(command)}' exited with {returncode}",
|
|
227
|
+
returncode=returncode,
|
|
228
|
+
cmd=redact_command(command),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run_interactive(
|
|
233
|
+
binary: str,
|
|
234
|
+
args: Sequence[str],
|
|
235
|
+
*,
|
|
236
|
+
cwd: Optional[str] = None,
|
|
237
|
+
env: Optional[dict] = None,
|
|
238
|
+
) -> int:
|
|
239
|
+
"""Runs a command with stdio inherited from the current process.
|
|
240
|
+
|
|
241
|
+
For subcommands that need a real terminal (attach, setup-token, an
|
|
242
|
+
interactive mcp/import picker) rather than captured output.
|
|
243
|
+
"""
|
|
244
|
+
command = [resolve_binary(binary, env), *args]
|
|
245
|
+
try:
|
|
246
|
+
return subprocess.call(command, cwd=cwd, env=env)
|
|
247
|
+
except FileNotFoundError as exc:
|
|
248
|
+
raise ClaudeNotFoundError(
|
|
249
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
250
|
+
cmd=redact_command(command),
|
|
251
|
+
) from exc
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def oauth_login(
|
|
255
|
+
binary: str,
|
|
256
|
+
args: Sequence[str],
|
|
257
|
+
*,
|
|
258
|
+
cwd: Optional[str] = None,
|
|
259
|
+
env: Optional[dict] = None,
|
|
260
|
+
code: Optional[str] = None,
|
|
261
|
+
code_provider: Optional[Callable[[str], str]] = None,
|
|
262
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
263
|
+
prompt_marker: str = "Paste code here",
|
|
264
|
+
timeout: float = 180,
|
|
265
|
+
) -> int:
|
|
266
|
+
"""Drives an interactive OAuth login (`claude auth login` / `mcp login`).
|
|
267
|
+
|
|
268
|
+
Streams the child's output (forwarding it to `on_output`, if given) until
|
|
269
|
+
it prints its "paste the code" prompt, then writes back `code` — or
|
|
270
|
+
whatever `code_provider(url)` returns, where `url` is the login URL
|
|
271
|
+
found in the output so far. Falls back to `input()` if neither is given.
|
|
272
|
+
Returns the child's exit code.
|
|
273
|
+
"""
|
|
274
|
+
command = [resolve_binary(binary, env), *args]
|
|
275
|
+
try:
|
|
276
|
+
proc = subprocess.Popen(
|
|
277
|
+
command,
|
|
278
|
+
cwd=cwd,
|
|
279
|
+
env=env,
|
|
280
|
+
stdin=subprocess.PIPE,
|
|
281
|
+
stdout=subprocess.PIPE,
|
|
282
|
+
stderr=subprocess.STDOUT,
|
|
283
|
+
text=True,
|
|
284
|
+
bufsize=1,
|
|
285
|
+
)
|
|
286
|
+
except FileNotFoundError as exc:
|
|
287
|
+
raise ClaudeNotFoundError(
|
|
288
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
289
|
+
cmd=redact_command(command),
|
|
290
|
+
) from exc
|
|
291
|
+
|
|
292
|
+
assert proc.stdout is not None and proc.stdin is not None
|
|
293
|
+
# Send exactly "\n", not the platform line ending: on Windows text mode
|
|
294
|
+
# would translate it to "\r\n" and the CLI would read a stray CR.
|
|
295
|
+
if hasattr(proc.stdin, "reconfigure"):
|
|
296
|
+
proc.stdin.reconfigure(newline="\n")
|
|
297
|
+
reader = _PipeReader(proc.stdout.fileno())
|
|
298
|
+
buffer = ""
|
|
299
|
+
url: Optional[str] = None
|
|
300
|
+
deadline = time.time() + timeout
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
while time.time() < deadline and proc.poll() is None:
|
|
304
|
+
chunk = reader.read(0.5)
|
|
305
|
+
if chunk is None: # the child closed its output
|
|
306
|
+
break
|
|
307
|
+
if not chunk:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
buffer += chunk
|
|
311
|
+
# A login that never reaches its prompt must not grow the buffer
|
|
312
|
+
# without bound; the marker and URL both live near the tail.
|
|
313
|
+
if len(buffer) > _MAX_LOGIN_BUFFER:
|
|
314
|
+
buffer = buffer[-_MAX_LOGIN_BUFFER:]
|
|
315
|
+
if on_output:
|
|
316
|
+
on_output(chunk)
|
|
317
|
+
|
|
318
|
+
if url is None:
|
|
319
|
+
match = _URL_PATTERN.search(buffer)
|
|
320
|
+
if match:
|
|
321
|
+
url = match.group(0)
|
|
322
|
+
|
|
323
|
+
if prompt_marker in buffer:
|
|
324
|
+
if code is not None:
|
|
325
|
+
entered_code = code
|
|
326
|
+
elif code_provider is not None:
|
|
327
|
+
entered_code = code_provider(url or "")
|
|
328
|
+
else:
|
|
329
|
+
entered_code = input("\nPaste the code from the browser here: ")
|
|
330
|
+
|
|
331
|
+
# Only ever hand the child a single line: a code carrying a
|
|
332
|
+
# newline would otherwise write extra lines into its stdin.
|
|
333
|
+
entered_code = str(entered_code or "").splitlines()
|
|
334
|
+
entered_code = entered_code[0].strip() if entered_code else ""
|
|
335
|
+
|
|
336
|
+
proc.stdin.write(entered_code + "\n")
|
|
337
|
+
proc.stdin.flush()
|
|
338
|
+
proc.stdin.close()
|
|
339
|
+
# Drop the code and the captured output rather than holding
|
|
340
|
+
# them in memory for the rest of the call.
|
|
341
|
+
entered_code = ""
|
|
342
|
+
buffer = ""
|
|
343
|
+
break
|
|
344
|
+
else:
|
|
345
|
+
if proc.poll() is None:
|
|
346
|
+
raise ClaudeTimeoutError(
|
|
347
|
+
f"'{format_command(command)}' did not produce a login prompt within {timeout}s",
|
|
348
|
+
cmd=redact_command(command),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
drain_deadline = time.time() + 30
|
|
352
|
+
while proc.poll() is None and time.time() < drain_deadline:
|
|
353
|
+
chunk = reader.read(0.5)
|
|
354
|
+
if chunk is None:
|
|
355
|
+
break
|
|
356
|
+
if chunk and on_output:
|
|
357
|
+
on_output(chunk)
|
|
358
|
+
|
|
359
|
+
# Reaching EOF on stdout does not mean the child has exited yet, so
|
|
360
|
+
# give it a moment to actually finish rather than calling it a timeout.
|
|
361
|
+
try:
|
|
362
|
+
return proc.wait(timeout=max(drain_deadline - time.time(), 5))
|
|
363
|
+
except subprocess.TimeoutExpired:
|
|
364
|
+
raise ClaudeTimeoutError(
|
|
365
|
+
f"'{format_command(command)}' did not finish after the code was submitted",
|
|
366
|
+
cmd=redact_command(command),
|
|
367
|
+
) from None
|
|
368
|
+
finally:
|
|
369
|
+
# Never leave a half-driven login (or its pipes) behind, whatever
|
|
370
|
+
# went wrong — including an exception raised by `code_provider`.
|
|
371
|
+
if proc.poll() is None:
|
|
372
|
+
proc.kill()
|
|
373
|
+
proc.wait()
|
|
374
|
+
for stream in (proc.stdin, proc.stdout):
|
|
375
|
+
try:
|
|
376
|
+
if stream is not None and not stream.closed:
|
|
377
|
+
stream.close()
|
|
378
|
+
except (OSError, ValueError):
|
|
379
|
+
pass
|