pyclaudecli 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,14 @@
1
+ """pyclaudecli: a Python library that wraps the `claude` CLI (Claude Code)."""
2
+
3
+ from .client import ClaudeCLI, build_flags
4
+ from .exceptions import ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "ClaudeCLI",
10
+ "build_flags",
11
+ "ClaudeCLIError",
12
+ "ClaudeNotFoundError",
13
+ "ClaudeTimeoutError",
14
+ ]
@@ -0,0 +1,61 @@
1
+ """`python -m pyclaudecli` — a minimal CLI wrapper for quick scripting.
2
+
3
+ For anything beyond a one-off prompt, use `pyclaudecli.ClaudeCLI` directly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import List, Optional, Tuple
10
+
11
+ from .client import ClaudeCLI
12
+
13
+
14
+ def parse_args(argv: List[str]) -> Tuple[str, str]:
15
+ model = "haiku"
16
+ prompt_parts = []
17
+ index = 0
18
+
19
+ while index < len(argv):
20
+ arg = argv[index]
21
+
22
+ if arg in {"--model", "-m"}:
23
+ if index + 1 >= len(argv):
24
+ raise SystemExit("Missing value for --model.")
25
+ model = argv[index + 1]
26
+ index += 2
27
+ continue
28
+
29
+ if arg.startswith("--model="):
30
+ model = arg.split("=", 1)[1]
31
+ index += 1
32
+ continue
33
+
34
+ prompt_parts.append(arg)
35
+ index += 1
36
+
37
+ prompt = " ".join(prompt_parts).strip() if prompt_parts else "Hello, Claude!"
38
+ return model, prompt
39
+
40
+
41
+ def main(argv: Optional[List[str]] = None) -> int:
42
+ if argv is None:
43
+ argv = sys.argv[1:]
44
+
45
+ model, prompt = parse_args(argv)
46
+ client = ClaudeCLI()
47
+ try:
48
+ result = client.run(["--print", "--model", model, prompt], check=False)
49
+ except Exception as exc: # ClaudeNotFoundError, etc.
50
+ print(str(exc), file=sys.stderr)
51
+ return 1
52
+
53
+ if result.stdout:
54
+ print(result.stdout, end="")
55
+ if result.stderr:
56
+ print(result.stderr, file=sys.stderr, end="")
57
+ return result.returncode
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
@@ -0,0 +1,241 @@
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
pyclaudecli/client.py ADDED
@@ -0,0 +1,581 @@
1
+ """ClaudeCLI: a Python wrapper around every `claude` CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
8
+
9
+ from . import _process
10
+ from .exceptions import ClaudeCLIError
11
+
12
+ _ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07")
13
+
14
+
15
+ def _kebab(name: str) -> str:
16
+ return "--" + name.replace("_", "-")
17
+
18
+
19
+ def build_flags(options: Dict[str, Any]) -> List[str]:
20
+ """Converts a {python_name: value} dict into CLI flags.
21
+
22
+ None/False -> omitted. True -> bare flag. list/tuple -> the flag once,
23
+ followed by each value (matches commander.js variadic `<x...>` options).
24
+ Anything else -> the flag followed by str(value).
25
+ """
26
+ flags: List[str] = []
27
+ for key, value in options.items():
28
+ if value is None or value is False:
29
+ continue
30
+ flag = _kebab(key)
31
+ if value is True:
32
+ flags.append(flag)
33
+ elif isinstance(value, (list, tuple)):
34
+ if not value:
35
+ continue
36
+ flags.append(flag)
37
+ flags.extend(str(v) for v in value)
38
+ else:
39
+ flags.append(flag)
40
+ flags.append(str(value))
41
+ return flags
42
+
43
+
44
+ class ClaudeCLI:
45
+ """Wraps the `claude` CLI (Claude Code) for programmatic use.
46
+
47
+ Every method shells out to the real `claude` binary, so whatever is
48
+ installed and authenticated on this machine is what runs. Methods that
49
+ capture output raise `ClaudeCLIError` (or a subclass) on a non-zero exit;
50
+ pass `check=False` to a raw call via `.run()` if you'd rather inspect the
51
+ result yourself.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ binary: str = "claude",
57
+ *,
58
+ cwd: Optional[str] = None,
59
+ env: Optional[dict] = None,
60
+ timeout: Optional[float] = None,
61
+ ) -> None:
62
+ self.binary = binary
63
+ self.cwd = cwd
64
+ self.env = env
65
+ self.timeout = timeout
66
+
67
+ # -- internals -----------------------------------------------------
68
+
69
+ def run(
70
+ self,
71
+ args: Sequence[str],
72
+ *,
73
+ input_text: Optional[str] = None,
74
+ timeout: Optional[float] = None,
75
+ check: bool = True,
76
+ ) -> _process.CommandResult:
77
+ """Runs `claude <args>` and returns the captured result."""
78
+ return _process.run(
79
+ self.binary,
80
+ args,
81
+ cwd=self.cwd,
82
+ env=self.env,
83
+ input_text=input_text,
84
+ timeout=timeout if timeout is not None else self.timeout,
85
+ check=check,
86
+ )
87
+
88
+ def _text(self, args: Sequence[str], **kwargs: Any) -> str:
89
+ return self.run(args, **kwargs).stdout
90
+
91
+ def _json(self, args: Sequence[str], **kwargs: Any) -> Any:
92
+ result = self.run(args, **kwargs)
93
+ try:
94
+ return json.loads(result.stdout)
95
+ except json.JSONDecodeError as exc:
96
+ raise ClaudeCLIError(
97
+ f"Expected JSON from `{self.binary} {' '.join(args)}`, "
98
+ f"got: {result.stdout[:200]!r}",
99
+ returncode=result.returncode,
100
+ stdout=result.stdout,
101
+ stderr=result.stderr,
102
+ cmd=[self.binary, *args],
103
+ ) from exc
104
+
105
+ def _interactive(self, args: Sequence[str]) -> int:
106
+ return _process.run_interactive(self.binary, args, cwd=self.cwd, env=self.env)
107
+
108
+ # -- version / health ------------------------------------------------
109
+
110
+ def version(self) -> str:
111
+ """`claude --version`"""
112
+ return self._text(["--version"]).strip()
113
+
114
+ def doctor(self) -> str:
115
+ """`claude doctor` — health-checks the local installation."""
116
+ return self._text(["doctor"])
117
+
118
+ def update(self) -> str:
119
+ """`claude update` — checks for and installs updates."""
120
+ return self._text(["update"])
121
+
122
+ def install(self, target: Optional[str] = None, *, force: bool = False) -> str:
123
+ """`claude install [target]` (target: stable, latest, or a version)."""
124
+ args = ["install"] + ([target] if target else []) + build_flags({"force": force})
125
+ return self._text(args)
126
+
127
+ # -- prompting (non-interactive / --print) ----------------------------
128
+
129
+ def prompt(
130
+ self,
131
+ text: str,
132
+ *,
133
+ model: Optional[str] = None,
134
+ output_format: Optional[str] = None,
135
+ system_prompt: Optional[str] = None,
136
+ append_system_prompt: Optional[str] = None,
137
+ allowed_tools: Optional[Sequence[str]] = None,
138
+ disallowed_tools: Optional[Sequence[str]] = None,
139
+ tools: Optional[Sequence[str]] = None,
140
+ add_dir: Optional[Sequence[str]] = None,
141
+ permission_mode: Optional[str] = None,
142
+ permission_prompts: Optional[str] = None,
143
+ mcp_config: Optional[Sequence[str]] = None,
144
+ settings: Optional[str] = None,
145
+ session_id: Optional[str] = None,
146
+ resume: Optional[str] = None,
147
+ continue_session: bool = False,
148
+ fork_session: bool = False,
149
+ effort: Optional[str] = None,
150
+ fallback_model: Optional[str] = None,
151
+ max_budget_usd: Optional[float] = None,
152
+ json_schema: Optional[str] = None,
153
+ betas: Optional[Sequence[str]] = None,
154
+ no_session_persistence: bool = False,
155
+ dangerously_skip_permissions: bool = False,
156
+ restricted: bool = False,
157
+ input_text: Optional[str] = None,
158
+ timeout: Optional[float] = None,
159
+ extra_flags: Optional[Dict[str, Any]] = None,
160
+ ) -> Union[str, dict]:
161
+ """Runs a single non-interactive prompt (`claude --print ...`).
162
+
163
+ Returns the response text, or the parsed JSON result object when
164
+ `output_format="json"`. Anything not exposed as a named parameter
165
+ (e.g. `--cloud`, `--worktree`, `--file`) can be passed via
166
+ `extra_flags={"worktree": True}`.
167
+ """
168
+ options = dict(
169
+ model=model,
170
+ output_format=output_format,
171
+ system_prompt=system_prompt,
172
+ append_system_prompt=append_system_prompt,
173
+ allowed_tools=allowed_tools,
174
+ disallowed_tools=disallowed_tools,
175
+ tools=tools,
176
+ add_dir=add_dir,
177
+ permission_mode=permission_mode,
178
+ permission_prompts=permission_prompts,
179
+ mcp_config=mcp_config,
180
+ settings=settings,
181
+ session_id=session_id,
182
+ resume=resume,
183
+ fork_session=fork_session,
184
+ effort=effort,
185
+ fallback_model=fallback_model,
186
+ max_budget_usd=max_budget_usd,
187
+ json_schema=json_schema,
188
+ betas=betas,
189
+ no_session_persistence=no_session_persistence,
190
+ dangerously_skip_permissions=dangerously_skip_permissions,
191
+ restricted=restricted,
192
+ )
193
+ options.update(extra_flags or {})
194
+ if options.get("output_format") == "stream-json":
195
+ options["verbose"] = True # required by the CLI for --print + stream-json
196
+
197
+ args = ["--print"] + build_flags(options)
198
+ if continue_session:
199
+ args.append("--continue")
200
+ args.append(text)
201
+
202
+ result = self.run(args, input_text=input_text, timeout=timeout)
203
+ if output_format == "json":
204
+ return json.loads(result.stdout)
205
+ return result.stdout
206
+
207
+ def prompt_json(self, text: str, **kwargs: Any) -> dict:
208
+ """`prompt()` forced to `--output-format json`; returns the parsed dict."""
209
+ kwargs["output_format"] = "json"
210
+ return self.prompt(text, **kwargs) # type: ignore[return-value]
211
+
212
+ def prompt_stream(self, text: str, **kwargs: Any) -> Iterator[dict]:
213
+ """`prompt()` with `--output-format stream-json`; yields each event dict."""
214
+ extra_flags = dict(kwargs.pop("extra_flags", None) or {})
215
+ extra_flags.update(kwargs)
216
+ extra_flags["output_format"] = "stream-json"
217
+ extra_flags["verbose"] = True # required by the CLI for --print + stream-json
218
+
219
+ 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.env):
222
+ line = line.strip()
223
+ if line:
224
+ yield json.loads(line)
225
+
226
+ # -- background sessions ("agents") -----------------------------------
227
+
228
+ def start_background(
229
+ self,
230
+ task: str,
231
+ *,
232
+ model: Optional[str] = None,
233
+ name: Optional[str] = None,
234
+ resume: Optional[str] = None,
235
+ extra_flags: Optional[Dict[str, Any]] = None,
236
+ ) -> str:
237
+ """`claude --bg <task>`; returns the short session id it prints."""
238
+ options = dict(model=model, name=name, resume=resume)
239
+ options.update(extra_flags or {})
240
+ args = ["--bg"] + build_flags(options) + [task]
241
+ output = self._text(args)
242
+ match = re.search(r"backgrounded\W*([0-9a-fA-F]+)", output)
243
+ if not match:
244
+ raise ClaudeCLIError(
245
+ f"Could not find a session id in `claude --bg` output: {output!r}",
246
+ stdout=output,
247
+ cmd=[self.binary, *args],
248
+ )
249
+ return match.group(1)
250
+
251
+ def list_agents(self, *, all: bool = False, cwd: Optional[str] = None) -> List[dict]:
252
+ """`claude agents --json`; lists interactive and background sessions."""
253
+ args = ["agents", "--json"] + build_flags({"all": all, "cwd": cwd})
254
+ return self._json(args)
255
+
256
+ def attach(self, session_id: str) -> int:
257
+ """`claude attach <id>` — opens a background session in this terminal.
258
+
259
+ Requires a real TTY (stdio is inherited, not captured).
260
+ """
261
+ return self._interactive(["attach", session_id])
262
+
263
+ def logs(self, session_id: str, *, strip_ansi: bool = False) -> str:
264
+ """`claude logs <id>` — a raw terminal snapshot of recent output.
265
+
266
+ Pass `strip_ansi=True` for a best-effort plain-text version.
267
+ """
268
+ text = self._text(["logs", session_id])
269
+ return _ANSI_PATTERN.sub("", text) if strip_ansi else text
270
+
271
+ def stop(self, session_id: str) -> str:
272
+ """`claude stop <id>` — stops a background session (keeps its state)."""
273
+ return self._text(["stop", session_id])
274
+
275
+ def rm(self, session_id: str) -> str:
276
+ """`claude rm <id>` — deletes a (stopped) background session."""
277
+ return self._text(["rm", session_id])
278
+
279
+ def respawn(self, session_id: Optional[str] = None, *, all: bool = False) -> str:
280
+ """`claude respawn [id]` / `claude respawn --all`."""
281
+ args = ["respawn"] + ([session_id] if session_id else []) + build_flags({"all": all})
282
+ return self._text(args)
283
+
284
+ # -- auth --------------------------------------------------------------
285
+
286
+ def auth_status(self, *, as_json: bool = True) -> Union[dict, str]:
287
+ """`claude auth status`."""
288
+ if as_json:
289
+ return self._json(["auth", "status", "--json"])
290
+ return self._text(["auth", "status", "--text"])
291
+
292
+ def auth_login(
293
+ self,
294
+ *,
295
+ email: Optional[str] = None,
296
+ console: bool = False,
297
+ sso: bool = False,
298
+ code: Optional[str] = None,
299
+ code_provider: Optional[Callable[[str], str]] = None,
300
+ on_output: Optional[Callable[[str], None]] = None,
301
+ timeout: float = 180,
302
+ ) -> int:
303
+ """Drives `claude auth login` end to end.
304
+
305
+ Prints (via `on_output`, or stdout if not given) the sign-in URL for
306
+ you to open and complete in a browser, then forwards the resulting
307
+ code back to the CLI. Pass `code` to supply it non-interactively, or
308
+ `code_provider(url)` to fetch it programmatically (e.g. from your own
309
+ browser-automation step); otherwise it's read from stdin.
310
+ """
311
+ args = ["auth", "login"]
312
+ args += build_flags({"console": console, "sso": sso, "email": email})
313
+ return _process.oauth_login(
314
+ self.binary,
315
+ args,
316
+ cwd=self.cwd,
317
+ env=self.env,
318
+ code=code,
319
+ code_provider=code_provider,
320
+ on_output=on_output or (lambda chunk: print(chunk, end="", flush=True)),
321
+ timeout=timeout,
322
+ )
323
+
324
+ def auth_logout(self) -> str:
325
+ """`claude auth logout`."""
326
+ return self._text(["auth", "logout"])
327
+
328
+ def setup_token(self) -> int:
329
+ """`claude setup-token` — sets up a long-lived auth token.
330
+
331
+ Interactive (may involve a browser step); stdio is inherited.
332
+ """
333
+ return self._interactive(["setup-token"])
334
+
335
+ # -- MCP servers ---------------------------------------------------------
336
+
337
+ def mcp_list(self) -> str:
338
+ """`claude mcp list`."""
339
+ return self._text(["mcp", "list"])
340
+
341
+ def mcp_get(self, name: str) -> str:
342
+ """`claude mcp get <name>`."""
343
+ return self._text(["mcp", "get", name])
344
+
345
+ def mcp_add(
346
+ self,
347
+ name: str,
348
+ command_or_url: str,
349
+ *args: str,
350
+ transport: Optional[str] = None,
351
+ header: Optional[Sequence[str]] = None,
352
+ env: Optional[Sequence[str]] = None,
353
+ scope: Optional[str] = None,
354
+ client_id: Optional[str] = None,
355
+ client_secret: bool = False,
356
+ callback_port: Optional[int] = None,
357
+ ) -> str:
358
+ """`claude mcp add <name> <commandOrUrl> [args...]`."""
359
+ options = build_flags(
360
+ dict(
361
+ transport=transport,
362
+ header=header,
363
+ env=env,
364
+ scope=scope,
365
+ client_id=client_id,
366
+ client_secret=client_secret,
367
+ callback_port=callback_port,
368
+ )
369
+ )
370
+ return self._text(["mcp", "add", *options, name, command_or_url, *args])
371
+
372
+ def mcp_add_json(
373
+ self,
374
+ name: str,
375
+ config: Union[str, dict],
376
+ *,
377
+ scope: Optional[str] = None,
378
+ client_secret: bool = False,
379
+ ) -> str:
380
+ """`claude mcp add-json <name> <json>`. `config` may be a dict or JSON string."""
381
+ payload = json.dumps(config) if isinstance(config, dict) else config
382
+ options = build_flags({"scope": scope, "client_secret": client_secret})
383
+ return self._text(["mcp", "add-json", *options, name, payload])
384
+
385
+ def mcp_add_from_claude_desktop(self, *, scope: Optional[str] = None) -> str:
386
+ """`claude mcp add-from-claude-desktop` (Mac and WSL only)."""
387
+ return self._text(["mcp", "add-from-claude-desktop", *build_flags({"scope": scope})])
388
+
389
+ def mcp_remove(self, name: str, *, scope: Optional[str] = None) -> str:
390
+ """`claude mcp remove <name>`."""
391
+ return self._text(["mcp", "remove", *build_flags({"scope": scope}), name])
392
+
393
+ def mcp_login(self, name: str, *, no_browser: bool = False) -> int:
394
+ """`claude mcp login <name>` — interactive OAuth for an MCP server."""
395
+ return self._interactive(["mcp", "login", *build_flags({"no_browser": no_browser}), name])
396
+
397
+ def mcp_logout(self, name: str) -> str:
398
+ """`claude mcp logout <name>`."""
399
+ return self._text(["mcp", "logout", name])
400
+
401
+ def mcp_reset_project_choices(self) -> str:
402
+ """`claude mcp reset-project-choices`."""
403
+ return self._text(["mcp", "reset-project-choices"])
404
+
405
+ # -- plugins ---------------------------------------------------------------
406
+
407
+ def plugin_list(self, *, as_json: bool = False) -> Union[str, list]:
408
+ """`claude plugin list`."""
409
+ args = ["plugin", "list"] + build_flags({"json": as_json})
410
+ return self._json(args) if as_json else self._text(args)
411
+
412
+ def plugin_install(
413
+ self,
414
+ plugin: str,
415
+ *,
416
+ scope: Optional[str] = None,
417
+ yes: bool = False,
418
+ config: Optional[Sequence[str]] = None,
419
+ accept_command: Optional[str] = None,
420
+ as_json: bool = False,
421
+ ) -> Union[str, dict]:
422
+ """`claude plugin install <plugin>` (use `plugin@marketplace` to pin one)."""
423
+ options = build_flags(
424
+ dict(scope=scope, yes=yes, config=config, accept_command=accept_command, json=as_json)
425
+ )
426
+ args = ["plugin", "install", *options, plugin]
427
+ return self._json(args) if as_json else self._text(args)
428
+
429
+ def plugin_uninstall(
430
+ self,
431
+ plugin: str,
432
+ *,
433
+ scope: Optional[str] = None,
434
+ keep_data: bool = False,
435
+ prune: bool = False,
436
+ yes: bool = False,
437
+ as_json: bool = False,
438
+ ) -> Union[str, dict]:
439
+ """`claude plugin uninstall <plugin>`."""
440
+ options = build_flags(
441
+ dict(scope=scope, keep_data=keep_data, prune=prune, yes=yes, json=as_json)
442
+ )
443
+ args = ["plugin", "uninstall", *options, plugin]
444
+ return self._json(args) if as_json else self._text(args)
445
+
446
+ def plugin_enable(self, plugin: str, *, scope: Optional[str] = None, as_json: bool = False) -> Union[str, dict]:
447
+ """`claude plugin enable <plugin>`."""
448
+ args = ["plugin", "enable", *build_flags({"scope": scope, "json": as_json}), plugin]
449
+ return self._json(args) if as_json else self._text(args)
450
+
451
+ def plugin_disable(
452
+ self,
453
+ plugin: Optional[str] = None,
454
+ *,
455
+ all: bool = False,
456
+ scope: Optional[str] = None,
457
+ as_json: bool = False,
458
+ ) -> Union[str, dict]:
459
+ """`claude plugin disable [plugin]` (or `all=True` for every plugin)."""
460
+ options = build_flags({"all": all, "scope": scope, "json": as_json})
461
+ args = ["plugin", "disable", *options] + ([plugin] if plugin else [])
462
+ return self._json(args) if as_json else self._text(args)
463
+
464
+ def plugin_update(
465
+ self,
466
+ plugin: str,
467
+ *,
468
+ scope: Optional[str] = None,
469
+ yes: bool = False,
470
+ accept_command: Optional[str] = None,
471
+ as_json: bool = False,
472
+ ) -> Union[str, dict]:
473
+ """`claude plugin update <plugin>`."""
474
+ options = build_flags(dict(scope=scope, yes=yes, accept_command=accept_command, json=as_json))
475
+ args = ["plugin", "update", *options, plugin]
476
+ return self._json(args) if as_json else self._text(args)
477
+
478
+ def plugin_details(self, name: str) -> str:
479
+ """`claude plugin details <name>`."""
480
+ return self._text(["plugin", "details", name])
481
+
482
+ def plugin_validate(self, path: str, *, strict: bool = False, as_json: bool = False) -> Union[str, dict]:
483
+ """`claude plugin validate <path>`."""
484
+ args = ["plugin", "validate", *build_flags({"strict": strict, "json": as_json}), path]
485
+ return self._json(args) if as_json else self._text(args)
486
+
487
+ def plugin_prune(self, *, scope: Optional[str] = None, yes: bool = False, dry_run: bool = False) -> str:
488
+ """`claude plugin prune` — removes orphaned auto-installed dependencies."""
489
+ args = ["plugin", "prune", *build_flags({"scope": scope, "yes": yes, "dry_run": dry_run})]
490
+ return self._text(args)
491
+
492
+ def plugin_marketplace_list(self) -> str:
493
+ """`claude plugin marketplace list`."""
494
+ return self._text(["plugin", "marketplace", "list"])
495
+
496
+ def plugin_marketplace_add(self, source: str) -> str:
497
+ """`claude plugin marketplace add <source>` (URL, path, or GitHub repo)."""
498
+ return self._text(["plugin", "marketplace", "add", source])
499
+
500
+ def plugin_marketplace_remove(self, name: str) -> str:
501
+ """`claude plugin marketplace remove <name>`."""
502
+ return self._text(["plugin", "marketplace", "remove", name])
503
+
504
+ def plugin_marketplace_update(self, name: Optional[str] = None) -> str:
505
+ """`claude plugin marketplace update [name]` (updates all if omitted)."""
506
+ return self._text(["plugin", "marketplace", "update"] + ([name] if name else []))
507
+
508
+ # -- project state -----------------------------------------------------
509
+
510
+ def project_purge(self, path: Optional[str] = None) -> str:
511
+ """`claude project purge [path]` — deletes all local state for a project."""
512
+ return self._text(["project", "purge"] + ([path] if path else []))
513
+
514
+ # -- auto mode -----------------------------------------------------------
515
+
516
+ def auto_mode_config(self) -> dict:
517
+ """`claude auto-mode config` — the effective classifier config as JSON."""
518
+ return self._json(["auto-mode", "config"])
519
+
520
+ def auto_mode_defaults(self) -> dict:
521
+ """`claude auto-mode defaults` — the shipped default rules as JSON."""
522
+ return self._json(["auto-mode", "defaults"])
523
+
524
+ def auto_mode_reset(self) -> str:
525
+ """`claude auto-mode reset` — removes custom rules from user settings."""
526
+ return self._text(["auto-mode", "reset"])
527
+
528
+ def auto_mode_critique(self) -> str:
529
+ """`claude auto-mode critique` — AI feedback on your custom rules."""
530
+ return self._text(["auto-mode", "critique"])
531
+
532
+ # -- misc ---------------------------------------------------------------
533
+
534
+ def import_config(
535
+ self,
536
+ source: str,
537
+ *,
538
+ dry_run: bool = False,
539
+ yes: Union[bool, str, None] = None,
540
+ ) -> str:
541
+ """`claude import <source>` (source: codex, gemini, or cursor).
542
+
543
+ Pass `dry_run=True` and/or `yes=True` (or a digest string) for
544
+ non-interactive use; otherwise the CLI may prompt interactively.
545
+ """
546
+ args = ["import", source] + build_flags({"dry_run": dry_run, "yes": yes})
547
+ return self._text(args)
548
+
549
+ def ultrareview(
550
+ self,
551
+ target: Optional[str] = None,
552
+ *,
553
+ as_json: bool = False,
554
+ post: bool = False,
555
+ timeout_minutes: Optional[int] = None,
556
+ timeout: Optional[float] = None,
557
+ ) -> Union[str, dict]:
558
+ """`claude ultrareview [target]` — cloud multi-agent code review.
559
+
560
+ `target` is a PR number or base branch; omit it to review the current
561
+ branch. `timeout` (seconds) bounds this call locally; `timeout_minutes`
562
+ is the CLI's own remote-review budget (default 45).
563
+ """
564
+ options = build_flags({"json": as_json, "post": post, "timeout": timeout_minutes})
565
+ args = ["ultrareview", *options] + ([target] if target else [])
566
+ result = self.run(args, timeout=timeout)
567
+ if as_json:
568
+ return json.loads(result.stdout)
569
+ return result.stdout
570
+
571
+ def start_gateway(self, *, config: Optional[str] = None):
572
+ """`claude gateway` — starts the enterprise auth/telemetry gateway.
573
+
574
+ This is a long-running server, so it's started with `Popen` (not
575
+ waited on) and the process handle is returned; call `.terminate()`
576
+ on it when you're done.
577
+ """
578
+ import subprocess
579
+
580
+ args = [self.binary, "gateway", *build_flags({"config": config})]
581
+ return subprocess.Popen(args, cwd=self.cwd, env=self.env)
@@ -0,0 +1,32 @@
1
+ """Exceptions raised by pyclaudecli."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Sequence
6
+
7
+
8
+ class ClaudeCLIError(Exception):
9
+ """Raised when the `claude` CLI exits with a non-zero status."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ returncode: Optional[int] = None,
16
+ stdout: Optional[str] = None,
17
+ stderr: Optional[str] = None,
18
+ cmd: Optional[Sequence[str]] = None,
19
+ ) -> None:
20
+ super().__init__(message)
21
+ self.returncode = returncode
22
+ self.stdout = stdout
23
+ self.stderr = stderr
24
+ self.cmd = list(cmd) if cmd is not None else None
25
+
26
+
27
+ class ClaudeNotFoundError(ClaudeCLIError):
28
+ """Raised when the `claude` binary can't be found on PATH."""
29
+
30
+
31
+ class ClaudeTimeoutError(ClaudeCLIError):
32
+ """Raised when a `claude` invocation exceeds its timeout."""
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyclaudecli
3
+ Version: 0.1.0
4
+ Summary: Python library wrapping the Claude Code CLI: prompts, background agents, auth, MCP, plugins, and more
5
+ Author-email: Suriya Ravichandran <suriyaravichandran@itrendsolution.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: anthropic,automation,claude,claude-code,cli,sdk,wrapper
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pyclaudecli
25
+
26
+ A Python library that wraps the [Claude Code](https://claude.com/claude-code) CLI (`claude`) so you can drive it from Python instead of shelling out by hand: one-shot prompts, JSON/streaming output, background agents, authentication, MCP servers, plugins, and the rest of the CLI's surface.
27
+
28
+ It's a thin wrapper, not a reimplementation — every call runs the real `claude` binary, so it always reflects whatever version, auth, and config you have installed locally.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install pyclaudecli
34
+ ```
35
+
36
+ Requires the `claude` CLI itself to be installed and on `PATH` (see the [Claude Code docs](https://claude.com/claude-code)).
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from pyclaudecli import ClaudeCLI
42
+
43
+ claude = ClaudeCLI()
44
+
45
+ # One-shot prompt
46
+ print(claude.prompt("Summarize this repo's README.", model="haiku"))
47
+
48
+ # Structured result (cost, session id, etc.)
49
+ result = claude.prompt_json("What's 2+2?", model="haiku")
50
+ print(result["result"], result["total_cost_usd"])
51
+
52
+ # Live streaming events
53
+ for event in claude.prompt_stream("Write a haiku about tests.", model="haiku"):
54
+ print(event["type"])
55
+ ```
56
+
57
+ ## Background agents
58
+
59
+ ```python
60
+ session_id = claude.start_background("Refactor the auth module", model="sonnet")
61
+ claude.list_agents()
62
+ claude.logs(session_id, strip_ansi=True)
63
+ claude.stop(session_id)
64
+ claude.rm(session_id)
65
+ ```
66
+
67
+ ## Auth
68
+
69
+ ```python
70
+ claude.auth_status() # {"loggedIn": True, "email": "...", ...}
71
+
72
+ # Prints the sign-in URL, then forwards the pasted code to finish login
73
+ claude.auth_login()
74
+ ```
75
+
76
+ ## MCP servers and plugins
77
+
78
+ ```python
79
+ claude.mcp_add("sentry", "https://mcp.sentry.dev/mcp", transport="http")
80
+ claude.mcp_list()
81
+
82
+ claude.plugin_install("some-plugin", yes=True)
83
+ claude.plugin_list(as_json=True)
84
+ ```
85
+
86
+ See `ClaudeCLI`'s docstrings for the full method list — 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.
87
+
88
+ ## Errors
89
+
90
+ All CLI failures raise `ClaudeCLIError` (or `ClaudeNotFoundError` / `ClaudeTimeoutError`), carrying `returncode`, `stdout`, and `stderr`.
91
+
92
+ ## License
93
+
94
+ MIT
@@ -0,0 +1,10 @@
1
+ pyclaudecli/__init__.py,sha256=mhEn48gp_bZiMgBmYu3QFNShOp8vbeFojYm5WJqeKbY,352
2
+ pyclaudecli/__main__.py,sha256=kof4pG7QHlkQr1w7xYqH8EwPAnNqj8PFjjCFX-RKy5E,1540
3
+ pyclaudecli/_process.py,sha256=jp7rvHqBY7XLE0CSrE9cI2mmEa61qlLBFW19EQDezv8,7076
4
+ pyclaudecli/client.py,sha256=ZBIzKc68Lmd6fYx8CzZCF9m7TvyzWPZ9iWDVLkLVivg,22830
5
+ pyclaudecli/exceptions.py,sha256=G2-5Vp2hBK-Q7jck-ybq2G6NRi5hHpxty9d7fLArUuA,870
6
+ pyclaudecli-0.1.0.dist-info/METADATA,sha256=3HW7O-83nukvLd1GwFKPVIY21dH_C8IJ-kE2XxKWWdw,3310
7
+ pyclaudecli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ pyclaudecli-0.1.0.dist-info/entry_points.txt,sha256=RzYhNNBMvPbe6g3zH4OwC1NLmz38mYEePKDtryrR5c4,58
9
+ pyclaudecli-0.1.0.dist-info/licenses/LICENSE,sha256=--FgFwZp9RlY-pXA4xV-pw-3oeGsyNk2vjIr3bQRnBY,1076
10
+ pyclaudecli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyclaudecli = pyclaudecli.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suriya Ravichandran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.