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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyclaudecli
3
- Version: 1.0.2
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.2"
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
 
@@ -3,7 +3,7 @@
3
3
  from .client import ClaudeCLI, build_flags
4
4
  from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
5
5
 
6
- __version__ = "1.0.2"
6
+ __version__ = "1.0.4"
7
7
 
8
8
  __all__ = [
9
9
  "ClaudeCLI",
@@ -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
- result = client.run(["--print", "--model", model, prompt], check=False)
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