pyclaudecli 1.0.2__tar.gz → 1.0.3__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.3}/PKG-INFO +48 -2
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/README.md +47 -1
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/pyproject.toml +1 -1
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/src/pyclaudecli/__init__.py +1 -1
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/src/pyclaudecli/__main__.py +3 -1
- pyclaudecli-1.0.3/src/pyclaudecli/_process.py +318 -0
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/src/pyclaudecli/client.py +35 -11
- pyclaudecli-1.0.3/tests/test_security.py +159 -0
- pyclaudecli-1.0.3/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.3}/.gitignore +0 -0
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/LICENSE +0 -0
- {pyclaudecli-1.0.2 → pyclaudecli-1.0.3}/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.3
|
|
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
|
|
@@ -41,6 +41,7 @@ It's a thin wrapper, not a reimplementation — every call runs the real `claude
|
|
|
41
41
|
- [Command-line usage](#command-line-usage)
|
|
42
42
|
- [Errors](#errors)
|
|
43
43
|
- [`build_flags`](#build_flags)
|
|
44
|
+
- [Security notes](#security-notes)
|
|
44
45
|
- [Every method, with an example](#every-method-with-an-example)
|
|
45
46
|
- [Development](#development)
|
|
46
47
|
- [License](#license)
|
|
@@ -81,6 +82,15 @@ claude = ClaudeCLI(
|
|
|
81
82
|
)
|
|
82
83
|
```
|
|
83
84
|
|
|
85
|
+
`env` is merged **over** the current environment, so `PATH`, `HOME` and the rest still
|
|
86
|
+
reach the CLI — passing one API key doesn't cost you the ability to find the binary or
|
|
87
|
+
its credentials. Pass `replace_env=True` if you really want the child to start from a
|
|
88
|
+
clean slate:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
|
|
92
|
+
```
|
|
93
|
+
|
|
84
94
|
## Authentication
|
|
85
95
|
|
|
86
96
|
`claude` handles auth itself, so `pyclaudecli` just drives it. You have two options.
|
|
@@ -410,13 +420,49 @@ gateway.terminate()
|
|
|
410
420
|
|
|
411
421
|
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
422
|
|
|
423
|
+
## Security notes
|
|
424
|
+
|
|
425
|
+
A few things the wrapper does on your behalf, worth knowing if you're feeding it input
|
|
426
|
+
from anywhere but your own code:
|
|
427
|
+
|
|
428
|
+
**Prompts can't smuggle in CLI flags.** `prompt()`, `prompt_stream()`, `start_background()`
|
|
429
|
+
and the `pyclaudecli` command put `--` between the options and your text, so a prompt that
|
|
430
|
+
starts with a dash is text, not a flag. Without it, user-supplied input like
|
|
431
|
+
`--dangerously-skip-permissions` or `--settings /tmp/evil.json` would be parsed by the CLI:
|
|
432
|
+
|
|
433
|
+
```python
|
|
434
|
+
claude.prompt("--version") # asks Claude about "--version"; does not run the flag
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Flags you actually want still go through named parameters or `extra_flags`. Note this
|
|
438
|
+
protection covers the prompt text — if you interpolate untrusted input into a *flag value*
|
|
439
|
+
(`model=`, `settings=`, `add_dir=`), validate it yourself.
|
|
440
|
+
|
|
441
|
+
**Errors don't spill credentials.** `ClaudeCLIError.args`/`.cmd` and the exception message
|
|
442
|
+
are redacted before they're raised, so MCP auth headers, injected `--env` values, tokens
|
|
443
|
+
inside an `mcp add-json` payload and anything shaped like `sk-ant-…`, `Bearer …` or
|
|
444
|
+
`password=…` come back as `<redacted>`. Flag names survive so the command is still
|
|
445
|
+
recognisable, and long values are truncated. Tracebacks and log aggregators get the
|
|
446
|
+
redacted form; `exc.stdout`/`exc.stderr` still hold the raw output for local debugging.
|
|
447
|
+
|
|
448
|
+
**Login codes are handled as single-use secrets.** `auth_login()` writes exactly one line
|
|
449
|
+
to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
|
|
450
|
+
code and the captured output from memory once it's submitted. The child process and its
|
|
451
|
+
pipes are always cleaned up, including when your `code_provider` raises.
|
|
452
|
+
|
|
453
|
+
Two things it deliberately does *not* do: it never runs a shell (every call is an argv
|
|
454
|
+
list, so there's no shell-injection surface), and it doesn't manage credentials itself —
|
|
455
|
+
auth lives with the `claude` CLI and your environment. Remember the argv of a running
|
|
456
|
+
process is visible to other users on the same machine via `ps`, so prefer env vars over
|
|
457
|
+
flags for anything sensitive.
|
|
458
|
+
|
|
413
459
|
## Development
|
|
414
460
|
|
|
415
461
|
```bash
|
|
416
462
|
git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
|
|
417
463
|
cd pyclaudecli
|
|
418
464
|
pip install -e .
|
|
419
|
-
pytest
|
|
465
|
+
pytest # offline; does not invoke the claude binary
|
|
420
466
|
```
|
|
421
467
|
|
|
422
468
|
## 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)
|
|
@@ -58,6 +59,15 @@ claude = ClaudeCLI(
|
|
|
58
59
|
)
|
|
59
60
|
```
|
|
60
61
|
|
|
62
|
+
`env` is merged **over** the current environment, so `PATH`, `HOME` and the rest still
|
|
63
|
+
reach the CLI — passing one API key doesn't cost you the ability to find the binary or
|
|
64
|
+
its credentials. Pass `replace_env=True` if you really want the child to start from a
|
|
65
|
+
clean slate:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
|
|
69
|
+
```
|
|
70
|
+
|
|
61
71
|
## Authentication
|
|
62
72
|
|
|
63
73
|
`claude` handles auth itself, so `pyclaudecli` just drives it. You have two options.
|
|
@@ -387,13 +397,49 @@ gateway.terminate()
|
|
|
387
397
|
|
|
388
398
|
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
399
|
|
|
400
|
+
## Security notes
|
|
401
|
+
|
|
402
|
+
A few things the wrapper does on your behalf, worth knowing if you're feeding it input
|
|
403
|
+
from anywhere but your own code:
|
|
404
|
+
|
|
405
|
+
**Prompts can't smuggle in CLI flags.** `prompt()`, `prompt_stream()`, `start_background()`
|
|
406
|
+
and the `pyclaudecli` command put `--` between the options and your text, so a prompt that
|
|
407
|
+
starts with a dash is text, not a flag. Without it, user-supplied input like
|
|
408
|
+
`--dangerously-skip-permissions` or `--settings /tmp/evil.json` would be parsed by the CLI:
|
|
409
|
+
|
|
410
|
+
```python
|
|
411
|
+
claude.prompt("--version") # asks Claude about "--version"; does not run the flag
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Flags you actually want still go through named parameters or `extra_flags`. Note this
|
|
415
|
+
protection covers the prompt text — if you interpolate untrusted input into a *flag value*
|
|
416
|
+
(`model=`, `settings=`, `add_dir=`), validate it yourself.
|
|
417
|
+
|
|
418
|
+
**Errors don't spill credentials.** `ClaudeCLIError.args`/`.cmd` and the exception message
|
|
419
|
+
are redacted before they're raised, so MCP auth headers, injected `--env` values, tokens
|
|
420
|
+
inside an `mcp add-json` payload and anything shaped like `sk-ant-…`, `Bearer …` or
|
|
421
|
+
`password=…` come back as `<redacted>`. Flag names survive so the command is still
|
|
422
|
+
recognisable, and long values are truncated. Tracebacks and log aggregators get the
|
|
423
|
+
redacted form; `exc.stdout`/`exc.stderr` still hold the raw output for local debugging.
|
|
424
|
+
|
|
425
|
+
**Login codes are handled as single-use secrets.** `auth_login()` writes exactly one line
|
|
426
|
+
to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
|
|
427
|
+
code and the captured output from memory once it's submitted. The child process and its
|
|
428
|
+
pipes are always cleaned up, including when your `code_provider` raises.
|
|
429
|
+
|
|
430
|
+
Two things it deliberately does *not* do: it never runs a shell (every call is an argv
|
|
431
|
+
list, so there's no shell-injection surface), and it doesn't manage credentials itself —
|
|
432
|
+
auth lives with the `claude` CLI and your environment. Remember the argv of a running
|
|
433
|
+
process is visible to other users on the same machine via `ps`, so prefer env vars over
|
|
434
|
+
flags for anything sensitive.
|
|
435
|
+
|
|
390
436
|
## Development
|
|
391
437
|
|
|
392
438
|
```bash
|
|
393
439
|
git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
|
|
394
440
|
cd pyclaudecli
|
|
395
441
|
pip install -e .
|
|
396
|
-
pytest
|
|
442
|
+
pytest # offline; does not invoke the claude binary
|
|
397
443
|
```
|
|
398
444
|
|
|
399
445
|
## 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.3"
|
|
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"
|
|
@@ -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,318 @@
|
|
|
1
|
+
"""Low-level process plumbing shared by ClaudeCLI's methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import select
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable, Iterator, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
|
|
14
|
+
|
|
15
|
+
_URL_PATTERN = re.compile(r"https://\S+")
|
|
16
|
+
|
|
17
|
+
# Flags whose values are credentials (MCP auth headers, injected env vars).
|
|
18
|
+
_SECRET_FLAGS = frozenset({"--header", "--env", "--client-id", "--client-secret"})
|
|
19
|
+
|
|
20
|
+
# Credential shapes that can turn up anywhere in an argv, including inside the
|
|
21
|
+
# JSON blob passed to `mcp add-json`.
|
|
22
|
+
_SECRET_VALUE_PATTERN = re.compile(
|
|
23
|
+
r"""(
|
|
24
|
+
sk-ant-[\w-]+ # Anthropic API keys
|
|
25
|
+
| pypi-[\w-]+ # PyPI tokens
|
|
26
|
+
| gh[pousr]_[A-Za-z0-9]+ # GitHub tokens
|
|
27
|
+
| [Bb]earer\s+\S+ # bearer tokens
|
|
28
|
+
| (?i:[\w.-]*(?:token|secret|key|password|passwd|auth)[\w.-]*)
|
|
29
|
+
\s*["\s]*[=:]\s*"?[^\s"',}]+ # anything token/secret/key-ish = value
|
|
30
|
+
)""",
|
|
31
|
+
re.VERBOSE,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_REDACTED = "<redacted>"
|
|
35
|
+
_MAX_ARG_LEN = 120
|
|
36
|
+
_MAX_DETAIL_LEN = 2000
|
|
37
|
+
_MAX_LOGIN_BUFFER = 64 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def redact_arg(value: str, limit: Optional[int] = _MAX_ARG_LEN) -> str:
|
|
41
|
+
"""Masks credential-looking substrings, and truncates very long values."""
|
|
42
|
+
cleaned = _SECRET_VALUE_PATTERN.sub(_REDACTED, str(value))
|
|
43
|
+
if limit is not None and len(cleaned) > limit:
|
|
44
|
+
cleaned = cleaned[:limit] + "…"
|
|
45
|
+
return cleaned
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact_command(command: Sequence[str]) -> list:
|
|
49
|
+
"""Returns a copy of an argv safe to put in an error message or a log.
|
|
50
|
+
|
|
51
|
+
Values of credential-bearing flags are replaced wholesale; everything else
|
|
52
|
+
is scanned for embedded secrets and truncated. Flag *names* survive, so the
|
|
53
|
+
command stays recognisable when debugging.
|
|
54
|
+
"""
|
|
55
|
+
safe = []
|
|
56
|
+
masking = False
|
|
57
|
+
for token in command:
|
|
58
|
+
token = str(token)
|
|
59
|
+
if token.startswith("-"):
|
|
60
|
+
masking = token in _SECRET_FLAGS
|
|
61
|
+
safe.append(token)
|
|
62
|
+
continue
|
|
63
|
+
safe.append(_REDACTED if masking else redact_arg(token))
|
|
64
|
+
return safe
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_command(command: Sequence[str]) -> str:
|
|
68
|
+
"""A redacted, shell-ish rendering of an argv for error messages."""
|
|
69
|
+
return " ".join(redact_command(command))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CommandResult:
|
|
74
|
+
args: list
|
|
75
|
+
returncode: int
|
|
76
|
+
stdout: str
|
|
77
|
+
stderr: str
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ok(self) -> bool:
|
|
81
|
+
return self.returncode == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run(
|
|
85
|
+
binary: str,
|
|
86
|
+
args: Sequence[str],
|
|
87
|
+
*,
|
|
88
|
+
cwd: Optional[str] = None,
|
|
89
|
+
env: Optional[dict] = None,
|
|
90
|
+
input_text: Optional[str] = None,
|
|
91
|
+
timeout: Optional[float] = None,
|
|
92
|
+
check: bool = True,
|
|
93
|
+
) -> CommandResult:
|
|
94
|
+
command = [binary, *args]
|
|
95
|
+
try:
|
|
96
|
+
completed = subprocess.run(
|
|
97
|
+
command,
|
|
98
|
+
cwd=cwd,
|
|
99
|
+
env=env,
|
|
100
|
+
input=input_text,
|
|
101
|
+
capture_output=True,
|
|
102
|
+
text=True,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
)
|
|
105
|
+
except FileNotFoundError as exc:
|
|
106
|
+
raise ClaudeNotFoundError(
|
|
107
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
108
|
+
cmd=redact_command(command),
|
|
109
|
+
) from exc
|
|
110
|
+
except subprocess.TimeoutExpired as exc:
|
|
111
|
+
stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
112
|
+
stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
113
|
+
raise ClaudeTimeoutError(
|
|
114
|
+
f"'{format_command(command)}' did not finish within {timeout}s",
|
|
115
|
+
cmd=redact_command(command),
|
|
116
|
+
stdout=stdout,
|
|
117
|
+
stderr=stderr,
|
|
118
|
+
) from exc
|
|
119
|
+
|
|
120
|
+
result = CommandResult(command, completed.returncode, completed.stdout, completed.stderr)
|
|
121
|
+
if check and not result.ok:
|
|
122
|
+
detail = redact_arg((result.stderr or result.stdout or "").strip(), limit=_MAX_DETAIL_LEN)
|
|
123
|
+
raise ClaudeCLIError(
|
|
124
|
+
f"'{format_command(command)}' exited with {result.returncode}: {detail}",
|
|
125
|
+
returncode=result.returncode,
|
|
126
|
+
stdout=result.stdout,
|
|
127
|
+
stderr=result.stderr,
|
|
128
|
+
cmd=redact_command(command),
|
|
129
|
+
)
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def stream_lines(
|
|
134
|
+
binary: str,
|
|
135
|
+
args: Sequence[str],
|
|
136
|
+
*,
|
|
137
|
+
cwd: Optional[str] = None,
|
|
138
|
+
env: Optional[dict] = None,
|
|
139
|
+
) -> Iterator[str]:
|
|
140
|
+
"""Runs a command and yields decoded stdout lines as they arrive."""
|
|
141
|
+
command = [binary, *args]
|
|
142
|
+
try:
|
|
143
|
+
proc = subprocess.Popen(
|
|
144
|
+
command,
|
|
145
|
+
cwd=cwd,
|
|
146
|
+
env=env,
|
|
147
|
+
stdout=subprocess.PIPE,
|
|
148
|
+
stderr=subprocess.STDOUT,
|
|
149
|
+
text=True,
|
|
150
|
+
bufsize=1,
|
|
151
|
+
)
|
|
152
|
+
except FileNotFoundError as exc:
|
|
153
|
+
raise ClaudeNotFoundError(
|
|
154
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
155
|
+
cmd=redact_command(command),
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
assert proc.stdout is not None
|
|
159
|
+
try:
|
|
160
|
+
for line in proc.stdout:
|
|
161
|
+
yield line.rstrip("\n")
|
|
162
|
+
finally:
|
|
163
|
+
proc.stdout.close()
|
|
164
|
+
returncode = proc.wait()
|
|
165
|
+
if returncode != 0:
|
|
166
|
+
raise ClaudeCLIError(
|
|
167
|
+
f"'{format_command(command)}' exited with {returncode}",
|
|
168
|
+
returncode=returncode,
|
|
169
|
+
cmd=redact_command(command),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_interactive(
|
|
174
|
+
binary: str,
|
|
175
|
+
args: Sequence[str],
|
|
176
|
+
*,
|
|
177
|
+
cwd: Optional[str] = None,
|
|
178
|
+
env: Optional[dict] = None,
|
|
179
|
+
) -> int:
|
|
180
|
+
"""Runs a command with stdio inherited from the current process.
|
|
181
|
+
|
|
182
|
+
For subcommands that need a real terminal (attach, setup-token, an
|
|
183
|
+
interactive mcp/import picker) rather than captured output.
|
|
184
|
+
"""
|
|
185
|
+
command = [binary, *args]
|
|
186
|
+
try:
|
|
187
|
+
return subprocess.call(command, cwd=cwd, env=env)
|
|
188
|
+
except FileNotFoundError as exc:
|
|
189
|
+
raise ClaudeNotFoundError(
|
|
190
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
191
|
+
cmd=redact_command(command),
|
|
192
|
+
) from exc
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def oauth_login(
|
|
196
|
+
binary: str,
|
|
197
|
+
args: Sequence[str],
|
|
198
|
+
*,
|
|
199
|
+
cwd: Optional[str] = None,
|
|
200
|
+
env: Optional[dict] = None,
|
|
201
|
+
code: Optional[str] = None,
|
|
202
|
+
code_provider: Optional[Callable[[str], str]] = None,
|
|
203
|
+
on_output: Optional[Callable[[str], None]] = None,
|
|
204
|
+
prompt_marker: str = "Paste code here",
|
|
205
|
+
timeout: float = 180,
|
|
206
|
+
) -> int:
|
|
207
|
+
"""Drives an interactive OAuth login (`claude auth login` / `mcp login`).
|
|
208
|
+
|
|
209
|
+
Streams the child's output (forwarding it to `on_output`, if given) until
|
|
210
|
+
it prints its "paste the code" prompt, then writes back `code` — or
|
|
211
|
+
whatever `code_provider(url)` returns, where `url` is the login URL
|
|
212
|
+
found in the output so far. Falls back to `input()` if neither is given.
|
|
213
|
+
Returns the child's exit code.
|
|
214
|
+
"""
|
|
215
|
+
command = [binary, *args]
|
|
216
|
+
try:
|
|
217
|
+
proc = subprocess.Popen(
|
|
218
|
+
command,
|
|
219
|
+
cwd=cwd,
|
|
220
|
+
env=env,
|
|
221
|
+
stdin=subprocess.PIPE,
|
|
222
|
+
stdout=subprocess.PIPE,
|
|
223
|
+
stderr=subprocess.STDOUT,
|
|
224
|
+
text=True,
|
|
225
|
+
bufsize=1,
|
|
226
|
+
)
|
|
227
|
+
except FileNotFoundError as exc:
|
|
228
|
+
raise ClaudeNotFoundError(
|
|
229
|
+
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
230
|
+
cmd=redact_command(command),
|
|
231
|
+
) from exc
|
|
232
|
+
|
|
233
|
+
assert proc.stdout is not None and proc.stdin is not None
|
|
234
|
+
fd = proc.stdout.fileno()
|
|
235
|
+
buffer = ""
|
|
236
|
+
url: Optional[str] = None
|
|
237
|
+
deadline = time.time() + timeout
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
while time.time() < deadline and proc.poll() is None:
|
|
241
|
+
ready, _, _ = select.select([fd], [], [], 0.5)
|
|
242
|
+
if not ready:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
chunk = os.read(fd, 4096).decode(errors="replace")
|
|
246
|
+
if not chunk:
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
buffer += chunk
|
|
250
|
+
# A login that never reaches its prompt must not grow the buffer
|
|
251
|
+
# without bound; the marker and URL both live near the tail.
|
|
252
|
+
if len(buffer) > _MAX_LOGIN_BUFFER:
|
|
253
|
+
buffer = buffer[-_MAX_LOGIN_BUFFER:]
|
|
254
|
+
if on_output:
|
|
255
|
+
on_output(chunk)
|
|
256
|
+
|
|
257
|
+
if url is None:
|
|
258
|
+
match = _URL_PATTERN.search(buffer)
|
|
259
|
+
if match:
|
|
260
|
+
url = match.group(0)
|
|
261
|
+
|
|
262
|
+
if prompt_marker in buffer:
|
|
263
|
+
if code is not None:
|
|
264
|
+
entered_code = code
|
|
265
|
+
elif code_provider is not None:
|
|
266
|
+
entered_code = code_provider(url or "")
|
|
267
|
+
else:
|
|
268
|
+
entered_code = input("\nPaste the code from the browser here: ")
|
|
269
|
+
|
|
270
|
+
# Only ever hand the child a single line: a code carrying a
|
|
271
|
+
# newline would otherwise write extra lines into its stdin.
|
|
272
|
+
entered_code = str(entered_code or "").splitlines()
|
|
273
|
+
entered_code = entered_code[0].strip() if entered_code else ""
|
|
274
|
+
|
|
275
|
+
proc.stdin.write(entered_code + "\n")
|
|
276
|
+
proc.stdin.flush()
|
|
277
|
+
proc.stdin.close()
|
|
278
|
+
# Drop the code and the captured output rather than holding
|
|
279
|
+
# them in memory for the rest of the call.
|
|
280
|
+
entered_code = ""
|
|
281
|
+
buffer = ""
|
|
282
|
+
break
|
|
283
|
+
else:
|
|
284
|
+
if proc.poll() is None:
|
|
285
|
+
raise ClaudeTimeoutError(
|
|
286
|
+
f"'{format_command(command)}' did not produce a login prompt within {timeout}s",
|
|
287
|
+
cmd=redact_command(command),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
drain_deadline = time.time() + 30
|
|
291
|
+
while proc.poll() is None and time.time() < drain_deadline:
|
|
292
|
+
ready, _, _ = select.select([fd], [], [], 0.5)
|
|
293
|
+
if ready:
|
|
294
|
+
chunk = os.read(fd, 4096).decode(errors="replace")
|
|
295
|
+
if not chunk:
|
|
296
|
+
break
|
|
297
|
+
if on_output:
|
|
298
|
+
on_output(chunk)
|
|
299
|
+
|
|
300
|
+
if proc.poll() is None:
|
|
301
|
+
raise ClaudeTimeoutError(
|
|
302
|
+
f"'{format_command(command)}' did not finish after the code was submitted",
|
|
303
|
+
cmd=redact_command(command),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return proc.returncode
|
|
307
|
+
finally:
|
|
308
|
+
# Never leave a half-driven login (or its pipes) behind, whatever
|
|
309
|
+
# went wrong — including an exception raised by `code_provider`.
|
|
310
|
+
if proc.poll() is None:
|
|
311
|
+
proc.kill()
|
|
312
|
+
proc.wait()
|
|
313
|
+
for stream in (proc.stdin, proc.stdout):
|
|
314
|
+
try:
|
|
315
|
+
if stream is not None and not stream.closed:
|
|
316
|
+
stream.close()
|
|
317
|
+
except (OSError, ValueError):
|
|
318
|
+
pass
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import re
|
|
7
8
|
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
|
|
8
9
|
|
|
@@ -58,14 +59,33 @@ class ClaudeCLI:
|
|
|
58
59
|
cwd: Optional[str] = None,
|
|
59
60
|
env: Optional[dict] = None,
|
|
60
61
|
timeout: Optional[float] = None,
|
|
62
|
+
replace_env: bool = False,
|
|
61
63
|
) -> None:
|
|
62
64
|
self.binary = binary
|
|
63
65
|
self.cwd = cwd
|
|
64
66
|
self.env = env
|
|
65
67
|
self.timeout = timeout
|
|
68
|
+
self.replace_env = replace_env
|
|
66
69
|
|
|
67
70
|
# -- internals -----------------------------------------------------
|
|
68
71
|
|
|
72
|
+
def _env(self) -> Optional[dict]:
|
|
73
|
+
"""The environment to hand the child process.
|
|
74
|
+
|
|
75
|
+
`env` is merged *over* the current environment by default. Replacing it
|
|
76
|
+
outright would drop PATH (so the binary may not resolve, or a different
|
|
77
|
+
one could), HOME and XDG_* (so `claude` would look for its credentials
|
|
78
|
+
and settings somewhere else), which is rarely what a caller passing a
|
|
79
|
+
single API key intends. Pass `replace_env=True` for a clean slate.
|
|
80
|
+
"""
|
|
81
|
+
if self.env is None:
|
|
82
|
+
return None
|
|
83
|
+
if self.replace_env:
|
|
84
|
+
return {str(k): str(v) for k, v in self.env.items()}
|
|
85
|
+
merged = os.environ.copy()
|
|
86
|
+
merged.update({str(k): str(v) for k, v in self.env.items()})
|
|
87
|
+
return merged
|
|
88
|
+
|
|
69
89
|
def run(
|
|
70
90
|
self,
|
|
71
91
|
args: Sequence[str],
|
|
@@ -79,7 +99,7 @@ class ClaudeCLI:
|
|
|
79
99
|
self.binary,
|
|
80
100
|
args,
|
|
81
101
|
cwd=self.cwd,
|
|
82
|
-
env=self.
|
|
102
|
+
env=self._env(),
|
|
83
103
|
input_text=input_text,
|
|
84
104
|
timeout=timeout if timeout is not None else self.timeout,
|
|
85
105
|
check=check,
|
|
@@ -93,17 +113,18 @@ class ClaudeCLI:
|
|
|
93
113
|
try:
|
|
94
114
|
return json.loads(result.stdout)
|
|
95
115
|
except json.JSONDecodeError as exc:
|
|
116
|
+
command = [self.binary, *args]
|
|
96
117
|
raise ClaudeCLIError(
|
|
97
|
-
f"Expected JSON from `{
|
|
98
|
-
f"got: {result.stdout[:200]!r}",
|
|
118
|
+
f"Expected JSON from `{_process.format_command(command)}`, "
|
|
119
|
+
f"got: {_process.redact_arg(result.stdout[:200])!r}",
|
|
99
120
|
returncode=result.returncode,
|
|
100
121
|
stdout=result.stdout,
|
|
101
122
|
stderr=result.stderr,
|
|
102
|
-
cmd=
|
|
123
|
+
cmd=_process.redact_command(command),
|
|
103
124
|
) from exc
|
|
104
125
|
|
|
105
126
|
def _interactive(self, args: Sequence[str]) -> int:
|
|
106
|
-
return _process.run_interactive(self.binary, args, cwd=self.cwd, env=self.
|
|
127
|
+
return _process.run_interactive(self.binary, args, cwd=self.cwd, env=self._env())
|
|
107
128
|
|
|
108
129
|
# -- version / health ------------------------------------------------
|
|
109
130
|
|
|
@@ -197,7 +218,10 @@ class ClaudeCLI:
|
|
|
197
218
|
args = ["--print"] + build_flags(options)
|
|
198
219
|
if continue_session:
|
|
199
220
|
args.append("--continue")
|
|
200
|
-
|
|
221
|
+
# `--` ends option parsing: without it a prompt that happens to start
|
|
222
|
+
# with a dash is read as CLI flags, so untrusted text could turn on
|
|
223
|
+
# things like --dangerously-skip-permissions.
|
|
224
|
+
args += ["--", text]
|
|
201
225
|
|
|
202
226
|
result = self.run(args, input_text=input_text, timeout=timeout)
|
|
203
227
|
if output_format == "json":
|
|
@@ -217,8 +241,8 @@ class ClaudeCLI:
|
|
|
217
241
|
extra_flags["verbose"] = True # required by the CLI for --print + stream-json
|
|
218
242
|
|
|
219
243
|
options = {k: v for k, v in extra_flags.items() if v is not None and v is not False}
|
|
220
|
-
args = ["--print"] + build_flags(options) + [text]
|
|
221
|
-
for line in _process.stream_lines(self.binary, args, cwd=self.cwd, env=self.
|
|
244
|
+
args = ["--print"] + build_flags(options) + ["--", text]
|
|
245
|
+
for line in _process.stream_lines(self.binary, args, cwd=self.cwd, env=self._env()):
|
|
222
246
|
line = line.strip()
|
|
223
247
|
if line:
|
|
224
248
|
yield json.loads(line)
|
|
@@ -237,7 +261,7 @@ class ClaudeCLI:
|
|
|
237
261
|
"""`claude --bg <task>`; returns the short session id it prints."""
|
|
238
262
|
options = dict(model=model, name=name, resume=resume)
|
|
239
263
|
options.update(extra_flags or {})
|
|
240
|
-
args = ["--bg"] + build_flags(options) + [task]
|
|
264
|
+
args = ["--bg"] + build_flags(options) + ["--", task]
|
|
241
265
|
output = self._text(args)
|
|
242
266
|
match = re.search(r"backgrounded\W*([0-9a-fA-F]+)", output)
|
|
243
267
|
if not match:
|
|
@@ -314,7 +338,7 @@ class ClaudeCLI:
|
|
|
314
338
|
self.binary,
|
|
315
339
|
args,
|
|
316
340
|
cwd=self.cwd,
|
|
317
|
-
env=self.
|
|
341
|
+
env=self._env(),
|
|
318
342
|
code=code,
|
|
319
343
|
code_provider=code_provider,
|
|
320
344
|
on_output=on_output or (lambda chunk: print(chunk, end="", flush=True)),
|
|
@@ -578,4 +602,4 @@ class ClaudeCLI:
|
|
|
578
602
|
import subprocess
|
|
579
603
|
|
|
580
604
|
args = [self.binary, "gateway", *build_flags({"config": config})]
|
|
581
|
-
return subprocess.Popen(args, cwd=self.cwd, env=self.
|
|
605
|
+
return subprocess.Popen(args, cwd=self.cwd, env=self._env())
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Regression tests for the hardening in 1.0.3 — all offline, no `claude` needed."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from pyclaudecli import ClaudeCLI, build_flags
|
|
8
|
+
from pyclaudecli._process import format_command, redact_arg, redact_command
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RecordingCLI(ClaudeCLI):
|
|
12
|
+
"""Captures the argv a method would run instead of executing it."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, **kwargs):
|
|
15
|
+
super().__init__(**kwargs)
|
|
16
|
+
self.calls = []
|
|
17
|
+
|
|
18
|
+
def run(self, args, **kwargs):
|
|
19
|
+
self.calls.append(list(args))
|
|
20
|
+
raise AssertionError("not executed")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def argv_for(method, *args, **kwargs):
|
|
24
|
+
client = RecordingCLI()
|
|
25
|
+
with pytest.raises(AssertionError):
|
|
26
|
+
getattr(client, method)(*args, **kwargs)
|
|
27
|
+
return client.calls[-1]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# -- argument injection ---------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def test_prompt_separates_options_from_text():
|
|
33
|
+
argv = argv_for("prompt", "--version")
|
|
34
|
+
assert argv[-2:] == ["--", "--version"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_prompt_separator_comes_after_continue():
|
|
38
|
+
argv = argv_for("prompt", "-h", continue_session=True)
|
|
39
|
+
assert argv.index("--continue") < argv.index("--")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.parametrize(
|
|
43
|
+
"hostile",
|
|
44
|
+
["--version", "--dangerously-skip-permissions", "-p", "--settings=/tmp/evil.json"],
|
|
45
|
+
)
|
|
46
|
+
def test_hostile_prompts_stay_positional(hostile):
|
|
47
|
+
argv = argv_for("prompt", hostile)
|
|
48
|
+
assert argv[-1] == hostile
|
|
49
|
+
assert argv[argv.index("--") + 1] == hostile
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_start_background_separates_task():
|
|
53
|
+
argv = argv_for("start_background", "--version")
|
|
54
|
+
assert argv[-2:] == ["--", "--version"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_module_entrypoint_separates_prompt():
|
|
58
|
+
from pyclaudecli.__main__ import main
|
|
59
|
+
|
|
60
|
+
captured = {}
|
|
61
|
+
|
|
62
|
+
class Fake(ClaudeCLI):
|
|
63
|
+
def run(self, args, **kwargs):
|
|
64
|
+
captured["argv"] = list(args)
|
|
65
|
+
raise SystemExit(0)
|
|
66
|
+
|
|
67
|
+
import pyclaudecli.__main__ as entry
|
|
68
|
+
|
|
69
|
+
original = entry.ClaudeCLI
|
|
70
|
+
entry.ClaudeCLI = Fake
|
|
71
|
+
try:
|
|
72
|
+
with pytest.raises(SystemExit):
|
|
73
|
+
main(["--version"])
|
|
74
|
+
finally:
|
|
75
|
+
entry.ClaudeCLI = original
|
|
76
|
+
|
|
77
|
+
assert captured["argv"][-2:] == ["--", "--version"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# -- secret redaction -----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def test_redacts_values_of_credential_flags():
|
|
83
|
+
argv = ["claude", "mcp", "add", "--header", "Authorization: Bearer tok123", "s", "https://x"]
|
|
84
|
+
safe = redact_command(argv)
|
|
85
|
+
assert "tok123" not in " ".join(safe)
|
|
86
|
+
assert "--header" in safe # flag names stay, for debuggability
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_redacts_env_flag_values():
|
|
90
|
+
safe = format_command(["claude", "mcp", "add", "--env", "API_KEY=supersecret", "s", "cmd"])
|
|
91
|
+
assert "supersecret" not in safe
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize(
|
|
95
|
+
"secret",
|
|
96
|
+
[
|
|
97
|
+
"sk-ant-api03-abcdef123456",
|
|
98
|
+
"pypi-AgEIcHlwaS5vcmcXYZ",
|
|
99
|
+
"ghp_abcdefghijklmnop",
|
|
100
|
+
'{"token": "abcdef123456"}',
|
|
101
|
+
"password=hunter2",
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
def test_redacts_credential_shapes_anywhere(secret):
|
|
105
|
+
assert secret not in redact_arg(secret)
|
|
106
|
+
assert "<redacted>" in redact_arg(secret)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_add_json_payload_is_redacted():
|
|
110
|
+
payload = '{"type":"http","url":"https://x","headers":{"Authorization":"Bearer sk-ant-abc123"}}'
|
|
111
|
+
assert "sk-ant-abc123" not in format_command(["claude", "mcp", "add-json", "s", payload])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_long_arguments_are_truncated():
|
|
115
|
+
assert len(redact_arg("a" * 5000)) < 200
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_ordinary_arguments_survive():
|
|
119
|
+
argv = ["claude", "--print", "--model", "haiku", "--", "Summarize this repo"]
|
|
120
|
+
assert redact_command(argv) == argv
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -- environment handling -------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def test_env_is_merged_over_os_environ_by_default():
|
|
126
|
+
env = ClaudeCLI(env={"ANTHROPIC_API_KEY": "sk-ant-x"})._env()
|
|
127
|
+
assert env["ANTHROPIC_API_KEY"] == "sk-ant-x"
|
|
128
|
+
assert env.get("PATH") == os.environ.get("PATH")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_replace_env_gives_a_clean_slate():
|
|
132
|
+
env = ClaudeCLI(env={"ANTHROPIC_API_KEY": "sk-ant-x"}, replace_env=True)._env()
|
|
133
|
+
assert env == {"ANTHROPIC_API_KEY": "sk-ant-x"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_no_env_means_inherit():
|
|
137
|
+
assert ClaudeCLI()._env() is None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# -- unchanged behaviour --------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def test_build_flags_still_builds_flags():
|
|
143
|
+
assert build_flags({"model": "haiku", "verbose": True, "quiet": False, "add_dir": ["a", "b"]}) == [
|
|
144
|
+
"--model", "haiku", "--verbose", "--add-dir", "a", "b",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_cli_stderr_is_redacted_in_error_messages():
|
|
149
|
+
from pyclaudecli._process import redact_arg
|
|
150
|
+
|
|
151
|
+
stderr = 'invalid header "Authorization: Bearer sk-ant-leak123"'
|
|
152
|
+
assert "sk-ant-leak123" not in redact_arg(stderr, limit=2000)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_detail_redaction_keeps_long_errors_readable():
|
|
156
|
+
from pyclaudecli._process import redact_arg
|
|
157
|
+
|
|
158
|
+
long_error = "error: " + ("context " * 100)
|
|
159
|
+
assert len(redact_arg(long_error, limit=2000)) > 500
|
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
"""Low-level process plumbing shared by ClaudeCLI's methods."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import os
|
|
6
|
-
import re
|
|
7
|
-
import select
|
|
8
|
-
import subprocess
|
|
9
|
-
import time
|
|
10
|
-
from dataclasses import dataclass
|
|
11
|
-
from typing import Callable, Iterator, Optional, Sequence
|
|
12
|
-
|
|
13
|
-
from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
|
|
14
|
-
|
|
15
|
-
_URL_PATTERN = re.compile(r"https://\S+")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@dataclass
|
|
19
|
-
class CommandResult:
|
|
20
|
-
args: list
|
|
21
|
-
returncode: int
|
|
22
|
-
stdout: str
|
|
23
|
-
stderr: str
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def ok(self) -> bool:
|
|
27
|
-
return self.returncode == 0
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def run(
|
|
31
|
-
binary: str,
|
|
32
|
-
args: Sequence[str],
|
|
33
|
-
*,
|
|
34
|
-
cwd: Optional[str] = None,
|
|
35
|
-
env: Optional[dict] = None,
|
|
36
|
-
input_text: Optional[str] = None,
|
|
37
|
-
timeout: Optional[float] = None,
|
|
38
|
-
check: bool = True,
|
|
39
|
-
) -> CommandResult:
|
|
40
|
-
command = [binary, *args]
|
|
41
|
-
try:
|
|
42
|
-
completed = subprocess.run(
|
|
43
|
-
command,
|
|
44
|
-
cwd=cwd,
|
|
45
|
-
env=env,
|
|
46
|
-
input=input_text,
|
|
47
|
-
capture_output=True,
|
|
48
|
-
text=True,
|
|
49
|
-
timeout=timeout,
|
|
50
|
-
)
|
|
51
|
-
except FileNotFoundError as exc:
|
|
52
|
-
raise ClaudeNotFoundError(
|
|
53
|
-
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
54
|
-
cmd=command,
|
|
55
|
-
) from exc
|
|
56
|
-
except subprocess.TimeoutExpired as exc:
|
|
57
|
-
stdout = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
58
|
-
stderr = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
59
|
-
raise ClaudeTimeoutError(
|
|
60
|
-
f"'{' '.join(command)}' did not finish within {timeout}s",
|
|
61
|
-
cmd=command,
|
|
62
|
-
stdout=stdout,
|
|
63
|
-
stderr=stderr,
|
|
64
|
-
) from exc
|
|
65
|
-
|
|
66
|
-
result = CommandResult(command, completed.returncode, completed.stdout, completed.stderr)
|
|
67
|
-
if check and not result.ok:
|
|
68
|
-
detail = (result.stderr or result.stdout or "").strip()
|
|
69
|
-
raise ClaudeCLIError(
|
|
70
|
-
f"'{' '.join(command)}' exited with {result.returncode}: {detail}",
|
|
71
|
-
returncode=result.returncode,
|
|
72
|
-
stdout=result.stdout,
|
|
73
|
-
stderr=result.stderr,
|
|
74
|
-
cmd=command,
|
|
75
|
-
)
|
|
76
|
-
return result
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def stream_lines(
|
|
80
|
-
binary: str,
|
|
81
|
-
args: Sequence[str],
|
|
82
|
-
*,
|
|
83
|
-
cwd: Optional[str] = None,
|
|
84
|
-
env: Optional[dict] = None,
|
|
85
|
-
) -> Iterator[str]:
|
|
86
|
-
"""Runs a command and yields decoded stdout lines as they arrive."""
|
|
87
|
-
command = [binary, *args]
|
|
88
|
-
try:
|
|
89
|
-
proc = subprocess.Popen(
|
|
90
|
-
command,
|
|
91
|
-
cwd=cwd,
|
|
92
|
-
env=env,
|
|
93
|
-
stdout=subprocess.PIPE,
|
|
94
|
-
stderr=subprocess.STDOUT,
|
|
95
|
-
text=True,
|
|
96
|
-
bufsize=1,
|
|
97
|
-
)
|
|
98
|
-
except FileNotFoundError as exc:
|
|
99
|
-
raise ClaudeNotFoundError(
|
|
100
|
-
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
101
|
-
cmd=command,
|
|
102
|
-
) from exc
|
|
103
|
-
|
|
104
|
-
assert proc.stdout is not None
|
|
105
|
-
try:
|
|
106
|
-
for line in proc.stdout:
|
|
107
|
-
yield line.rstrip("\n")
|
|
108
|
-
finally:
|
|
109
|
-
proc.stdout.close()
|
|
110
|
-
returncode = proc.wait()
|
|
111
|
-
if returncode != 0:
|
|
112
|
-
raise ClaudeCLIError(
|
|
113
|
-
f"'{' '.join(command)}' exited with {returncode}",
|
|
114
|
-
returncode=returncode,
|
|
115
|
-
cmd=command,
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def run_interactive(
|
|
120
|
-
binary: str,
|
|
121
|
-
args: Sequence[str],
|
|
122
|
-
*,
|
|
123
|
-
cwd: Optional[str] = None,
|
|
124
|
-
env: Optional[dict] = None,
|
|
125
|
-
) -> int:
|
|
126
|
-
"""Runs a command with stdio inherited from the current process.
|
|
127
|
-
|
|
128
|
-
For subcommands that need a real terminal (attach, setup-token, an
|
|
129
|
-
interactive mcp/import picker) rather than captured output.
|
|
130
|
-
"""
|
|
131
|
-
command = [binary, *args]
|
|
132
|
-
try:
|
|
133
|
-
return subprocess.call(command, cwd=cwd, env=env)
|
|
134
|
-
except FileNotFoundError as exc:
|
|
135
|
-
raise ClaudeNotFoundError(
|
|
136
|
-
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
137
|
-
cmd=command,
|
|
138
|
-
) from exc
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def oauth_login(
|
|
142
|
-
binary: str,
|
|
143
|
-
args: Sequence[str],
|
|
144
|
-
*,
|
|
145
|
-
cwd: Optional[str] = None,
|
|
146
|
-
env: Optional[dict] = None,
|
|
147
|
-
code: Optional[str] = None,
|
|
148
|
-
code_provider: Optional[Callable[[str], str]] = None,
|
|
149
|
-
on_output: Optional[Callable[[str], None]] = None,
|
|
150
|
-
prompt_marker: str = "Paste code here",
|
|
151
|
-
timeout: float = 180,
|
|
152
|
-
) -> int:
|
|
153
|
-
"""Drives an interactive OAuth login (`claude auth login` / `mcp login`).
|
|
154
|
-
|
|
155
|
-
Streams the child's output (forwarding it to `on_output`, if given) until
|
|
156
|
-
it prints its "paste the code" prompt, then writes back `code` — or
|
|
157
|
-
whatever `code_provider(url)` returns, where `url` is the login URL
|
|
158
|
-
found in the output so far. Falls back to `input()` if neither is given.
|
|
159
|
-
Returns the child's exit code.
|
|
160
|
-
"""
|
|
161
|
-
command = [binary, *args]
|
|
162
|
-
try:
|
|
163
|
-
proc = subprocess.Popen(
|
|
164
|
-
command,
|
|
165
|
-
cwd=cwd,
|
|
166
|
-
env=env,
|
|
167
|
-
stdin=subprocess.PIPE,
|
|
168
|
-
stdout=subprocess.PIPE,
|
|
169
|
-
stderr=subprocess.STDOUT,
|
|
170
|
-
text=True,
|
|
171
|
-
bufsize=1,
|
|
172
|
-
)
|
|
173
|
-
except FileNotFoundError as exc:
|
|
174
|
-
raise ClaudeNotFoundError(
|
|
175
|
-
f"'{binary}' was not found on PATH. Is Claude Code installed?",
|
|
176
|
-
cmd=command,
|
|
177
|
-
) from exc
|
|
178
|
-
|
|
179
|
-
assert proc.stdout is not None and proc.stdin is not None
|
|
180
|
-
fd = proc.stdout.fileno()
|
|
181
|
-
buffer = ""
|
|
182
|
-
url: Optional[str] = None
|
|
183
|
-
deadline = time.time() + timeout
|
|
184
|
-
|
|
185
|
-
while time.time() < deadline and proc.poll() is None:
|
|
186
|
-
ready, _, _ = select.select([fd], [], [], 0.5)
|
|
187
|
-
if not ready:
|
|
188
|
-
continue
|
|
189
|
-
|
|
190
|
-
chunk = os.read(fd, 4096).decode(errors="replace")
|
|
191
|
-
if not chunk:
|
|
192
|
-
break
|
|
193
|
-
|
|
194
|
-
buffer += chunk
|
|
195
|
-
if on_output:
|
|
196
|
-
on_output(chunk)
|
|
197
|
-
|
|
198
|
-
if url is None:
|
|
199
|
-
match = _URL_PATTERN.search(buffer)
|
|
200
|
-
if match:
|
|
201
|
-
url = match.group(0)
|
|
202
|
-
|
|
203
|
-
if prompt_marker in buffer:
|
|
204
|
-
if code is not None:
|
|
205
|
-
entered_code = code
|
|
206
|
-
elif code_provider is not None:
|
|
207
|
-
entered_code = code_provider(url or "")
|
|
208
|
-
else:
|
|
209
|
-
entered_code = input("\nPaste the code from the browser here: ").strip()
|
|
210
|
-
|
|
211
|
-
proc.stdin.write(entered_code + "\n")
|
|
212
|
-
proc.stdin.flush()
|
|
213
|
-
proc.stdin.close()
|
|
214
|
-
buffer = ""
|
|
215
|
-
break
|
|
216
|
-
else:
|
|
217
|
-
if proc.poll() is None:
|
|
218
|
-
proc.kill()
|
|
219
|
-
raise ClaudeTimeoutError(
|
|
220
|
-
f"'{' '.join(command)}' did not produce a login prompt within {timeout}s",
|
|
221
|
-
cmd=command,
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
drain_deadline = time.time() + 30
|
|
225
|
-
while proc.poll() is None and time.time() < drain_deadline:
|
|
226
|
-
ready, _, _ = select.select([fd], [], [], 0.5)
|
|
227
|
-
if ready:
|
|
228
|
-
chunk = os.read(fd, 4096).decode(errors="replace")
|
|
229
|
-
if not chunk:
|
|
230
|
-
break
|
|
231
|
-
if on_output:
|
|
232
|
-
on_output(chunk)
|
|
233
|
-
|
|
234
|
-
if proc.poll() is None:
|
|
235
|
-
proc.kill()
|
|
236
|
-
raise ClaudeTimeoutError(
|
|
237
|
-
f"'{' '.join(command)}' did not finish after the code was submitted",
|
|
238
|
-
cmd=command,
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
return proc.returncode
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
from pyclaudecli import ClaudeCLI, ClaudeCLIError
|
|
2
|
-
|
|
3
|
-
claude = ClaudeCLI() # add cwd=..., env=... if needed
|
|
4
|
-
|
|
5
|
-
# Plain text answer
|
|
6
|
-
claude.prompt("Explain this function", model="haiku")
|
|
7
|
-
|
|
8
|
-
# Structured result: cost, session_id, etc.
|
|
9
|
-
result = claude.prompt_json("2+2?", model="haiku")
|
|
10
|
-
result["result"], result["total_cost_usd"]
|
|
11
|
-
|
|
12
|
-
# Live token/event stream
|
|
13
|
-
for event in claude.prompt_stream("Write a haiku"):
|
|
14
|
-
print(event["type"])
|
|
15
|
-
|
|
16
|
-
# Background agent (long-running task)
|
|
17
|
-
sid = claude.start_background("Refactor auth module", model="sonnet")
|
|
18
|
-
claude.list_agents()
|
|
19
|
-
claude.logs(sid, strip_ansi=True)
|
|
20
|
-
claude.stop(sid); claude.rm(sid)
|
|
21
|
-
|
|
22
|
-
# Auth
|
|
23
|
-
claude.auth_status()
|
|
24
|
-
claude.auth_login() # prints URL, prompts you for the pasted code
|
|
25
|
-
|
|
26
|
-
# MCP / plugins
|
|
27
|
-
claude.mcp_add("sentry", "https://mcp.sentry.dev/mcp", transport="http")
|
|
28
|
-
claude.plugin_install("some-plugin", yes=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|