gac 2.3.0__py3-none-any.whl → 2.7.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.

Potentially problematic release.


This version of gac might be problematic. Click here for more details.

@@ -0,0 +1,397 @@
1
+ """Claude Code OAuth authentication utilities.
2
+
3
+ Implements PKCE OAuth flow for Claude Code subscriptions.
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import logging
9
+ import secrets
10
+ import threading
11
+ import time
12
+ import webbrowser
13
+ from dataclasses import dataclass
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from pathlib import Path
16
+ from typing import Any, TypedDict
17
+ from urllib.parse import parse_qs, urlencode, urlparse
18
+
19
+ import httpx
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ClaudeCodeConfig(TypedDict):
25
+ """Type definition for Claude Code OAuth configuration."""
26
+
27
+ auth_url: str
28
+ token_url: str
29
+ api_base_url: str
30
+ client_id: str
31
+ scope: str
32
+ redirect_host: str
33
+ redirect_path: str
34
+ callback_port_range: tuple[int, int]
35
+ callback_timeout: int
36
+ anthropic_version: str
37
+
38
+
39
+ # Claude Code OAuth configuration
40
+ CLAUDE_CODE_CONFIG: ClaudeCodeConfig = {
41
+ "auth_url": "https://claude.ai/oauth/authorize",
42
+ "token_url": "https://console.anthropic.com/v1/oauth/token",
43
+ "api_base_url": "https://api.anthropic.com",
44
+ "client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
45
+ "scope": "org:create_api_key user:profile user:inference",
46
+ "redirect_host": "http://localhost",
47
+ "redirect_path": "callback",
48
+ "callback_port_range": (8765, 8795),
49
+ "callback_timeout": 180,
50
+ "anthropic_version": "2023-06-01",
51
+ }
52
+
53
+
54
+ @dataclass
55
+ class OAuthContext:
56
+ """Runtime state for an in-progress OAuth flow."""
57
+
58
+ state: str
59
+ code_verifier: str
60
+ code_challenge: str
61
+ created_at: float
62
+ redirect_uri: str | None = None
63
+
64
+
65
+ class _OAuthResult:
66
+ """Stores OAuth callback results."""
67
+
68
+ def __init__(self) -> None:
69
+ self.code: str | None = None
70
+ self.state: str | None = None
71
+ self.error: str | None = None
72
+
73
+
74
+ class _CallbackHandler(BaseHTTPRequestHandler):
75
+ """HTTP handler for OAuth callback."""
76
+
77
+ result: _OAuthResult
78
+ received_event: threading.Event
79
+
80
+ def do_GET(self) -> None: # noqa: N802
81
+ """Handle GET request from OAuth redirect."""
82
+ logger.info("OAuth callback received: path=%s", self.path)
83
+ parsed = urlparse(self.path)
84
+ params: dict[str, list[str]] = parse_qs(parsed.query)
85
+
86
+ code = params.get("code", [None])[0]
87
+ state = params.get("state", [None])[0]
88
+
89
+ if code and state:
90
+ self.result.code = code
91
+ self.result.state = state
92
+ success_html = _get_success_html()
93
+ self._write_response(200, success_html)
94
+ else:
95
+ self.result.error = "Missing code or state"
96
+ failure_html = _get_failure_html()
97
+ self._write_response(400, failure_html)
98
+
99
+ self.received_event.set()
100
+
101
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
102
+ """Suppress HTTP server logs."""
103
+ return
104
+
105
+ def _write_response(self, status: int, body: str) -> None:
106
+ """Write HTTP response."""
107
+ self.send_response(status)
108
+ self.send_header("Content-Type", "text/html; charset=utf-8")
109
+ self.end_headers()
110
+ self.wfile.write(body.encode("utf-8"))
111
+
112
+
113
+ def _get_success_html() -> str:
114
+ """Return HTML for successful authentication."""
115
+ return """
116
+ <!DOCTYPE html>
117
+ <html>
118
+ <head>
119
+ <title>Authentication Successful</title>
120
+ <style>
121
+ body { font-family: system-ui; text-align: center; padding: 50px; }
122
+ h1 { color: #10a37f; }
123
+ </style>
124
+ </head>
125
+ <body>
126
+ <h1>✓ Authentication Successful!</h1>
127
+ <p>You can close this window and return to your terminal.</p>
128
+ </body>
129
+ </html>
130
+ """
131
+
132
+
133
+ def _get_failure_html() -> str:
134
+ """Return HTML for failed authentication."""
135
+ return """
136
+ <!DOCTYPE html>
137
+ <html>
138
+ <head>
139
+ <title>Authentication Failed</title>
140
+ <style>
141
+ body { font-family: system-ui; text-align: center; padding: 50px; }
142
+ h1 { color: #ef4444; }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <h1>✗ Authentication Failed</h1>
147
+ <p>Missing authorization code. Please try again.</p>
148
+ </body>
149
+ </html>
150
+ """
151
+
152
+
153
+ def _urlsafe_b64encode(data: bytes) -> str:
154
+ """Base64url encode without padding."""
155
+ return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
156
+
157
+
158
+ def _generate_code_verifier() -> str:
159
+ """Generate PKCE code verifier."""
160
+ return _urlsafe_b64encode(secrets.token_bytes(64))
161
+
162
+
163
+ def _compute_code_challenge(code_verifier: str) -> str:
164
+ """Compute PKCE code challenge from verifier."""
165
+ digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
166
+ return _urlsafe_b64encode(digest)
167
+
168
+
169
+ def prepare_oauth_context() -> OAuthContext:
170
+ """Create a new OAuth PKCE context."""
171
+ state = secrets.token_urlsafe(32)
172
+ code_verifier = _generate_code_verifier()
173
+ code_challenge = _compute_code_challenge(code_verifier)
174
+ return OAuthContext(
175
+ state=state,
176
+ code_verifier=code_verifier,
177
+ code_challenge=code_challenge,
178
+ created_at=time.time(),
179
+ )
180
+
181
+
182
+ def build_authorization_url(context: OAuthContext) -> str:
183
+ """Build the Claude authorization URL with PKCE parameters."""
184
+ if not context.redirect_uri:
185
+ raise RuntimeError("Redirect URI has not been assigned for this OAuth context")
186
+
187
+ params = {
188
+ "response_type": "code",
189
+ "client_id": CLAUDE_CODE_CONFIG["client_id"],
190
+ "redirect_uri": context.redirect_uri,
191
+ "scope": CLAUDE_CODE_CONFIG["scope"],
192
+ "state": context.state,
193
+ "code": "true",
194
+ "code_challenge": context.code_challenge,
195
+ "code_challenge_method": "S256",
196
+ }
197
+ return f"{CLAUDE_CODE_CONFIG['auth_url']}?{urlencode(params)}"
198
+
199
+
200
+ def _start_callback_server(context: OAuthContext) -> tuple[HTTPServer, _OAuthResult, threading.Event] | None:
201
+ """Start local HTTP server to receive OAuth callback."""
202
+ port_range = CLAUDE_CODE_CONFIG["callback_port_range"]
203
+
204
+ for port in range(port_range[0], port_range[1] + 1):
205
+ try:
206
+ server = HTTPServer(("localhost", port), _CallbackHandler)
207
+ context.redirect_uri = f"{CLAUDE_CODE_CONFIG['redirect_host']}:{port}/{CLAUDE_CODE_CONFIG['redirect_path']}"
208
+ result = _OAuthResult()
209
+ event = threading.Event()
210
+ _CallbackHandler.result = result
211
+ _CallbackHandler.received_event = event
212
+
213
+ def run_server(srv: HTTPServer = server) -> None:
214
+ with srv:
215
+ srv.serve_forever()
216
+
217
+ threading.Thread(target=run_server, daemon=True).start()
218
+ return server, result, event
219
+ except OSError:
220
+ continue
221
+
222
+ logger.error("Could not start OAuth callback server; all candidate ports are in use")
223
+ return None
224
+
225
+
226
+ def exchange_code_for_tokens(auth_code: str, context: OAuthContext) -> dict[str, Any] | None:
227
+ """Exchange authorization code for access tokens."""
228
+ if not context.redirect_uri:
229
+ raise RuntimeError("Redirect URI missing from OAuth context")
230
+
231
+ payload = {
232
+ "grant_type": "authorization_code",
233
+ "client_id": CLAUDE_CODE_CONFIG["client_id"],
234
+ "code": auth_code,
235
+ "state": context.state,
236
+ "code_verifier": context.code_verifier,
237
+ "redirect_uri": context.redirect_uri,
238
+ }
239
+
240
+ headers = {
241
+ "Content-Type": "application/json",
242
+ "Accept": "application/json",
243
+ "anthropic-beta": "oauth-2025-04-20",
244
+ }
245
+
246
+ logger.info("Exchanging code for tokens: %s", CLAUDE_CODE_CONFIG["token_url"])
247
+ try:
248
+ response = httpx.post(
249
+ CLAUDE_CODE_CONFIG["token_url"],
250
+ json=payload,
251
+ headers=headers,
252
+ timeout=30,
253
+ )
254
+ logger.info("Token exchange response: %s", response.status_code)
255
+ if response.status_code == 200:
256
+ tokens: dict[str, Any] = response.json()
257
+ # Add expiry timestamp if not present
258
+ if "expires_at" not in tokens and "expires_in" in tokens:
259
+ tokens["expires_at"] = time.time() + tokens["expires_in"]
260
+ return tokens
261
+ logger.error("Token exchange failed: %s - %s", response.status_code, response.text)
262
+ except Exception as exc:
263
+ logger.error("Token exchange error: %s", exc)
264
+ return None
265
+
266
+
267
+ def perform_oauth_flow(quiet: bool = False) -> dict[str, Any] | None:
268
+ """Perform full OAuth flow and return tokens."""
269
+ context = prepare_oauth_context()
270
+
271
+ # Start callback server
272
+ started = _start_callback_server(context)
273
+ if not started:
274
+ if not quiet:
275
+ print("❌ Could not start OAuth callback server; all ports are in use")
276
+ return None
277
+
278
+ server, result, event = started
279
+ redirect_uri = context.redirect_uri
280
+
281
+ if not redirect_uri:
282
+ if not quiet:
283
+ print("❌ Failed to assign redirect URI for OAuth flow")
284
+ server.shutdown()
285
+ return None
286
+
287
+ # Build auth URL and open browser
288
+ auth_url = build_authorization_url(context)
289
+
290
+ if not quiet:
291
+ print("\n🔐 Opening browser for Claude Code OAuth authentication...")
292
+ print(f" If it doesn't open automatically, visit: {auth_url}\n")
293
+ print(f" Listening for callback on {redirect_uri}")
294
+ print(" (Waiting up to 3 minutes...)\n")
295
+
296
+ try:
297
+ webbrowser.open(auth_url)
298
+ except Exception as exc:
299
+ logger.warning("Failed to open browser automatically: %s", exc)
300
+ if not quiet:
301
+ print(f"⚠️ Failed to open browser automatically: {exc}")
302
+ print(f" Please open the URL manually: {auth_url}\n")
303
+
304
+ # Wait for callback
305
+ timeout = CLAUDE_CODE_CONFIG["callback_timeout"]
306
+ if not event.wait(timeout=timeout):
307
+ if not quiet:
308
+ print("❌ OAuth callback timed out. Please try again.")
309
+ server.shutdown()
310
+ return None
311
+
312
+ server.shutdown()
313
+
314
+ # Check for errors
315
+ if result.error:
316
+ if not quiet:
317
+ print(f"❌ OAuth callback error: {result.error}")
318
+ return None
319
+
320
+ # Validate state
321
+ if result.state != context.state:
322
+ if not quiet:
323
+ print("❌ State mismatch detected; aborting authentication for security")
324
+ return None
325
+
326
+ # Exchange code for tokens
327
+ if not quiet:
328
+ print("✓ Authorization code received")
329
+ print(" Exchanging for access token...\n")
330
+
331
+ tokens = exchange_code_for_tokens(result.code, context) # type: ignore[arg-type]
332
+ if not tokens:
333
+ if not quiet:
334
+ print("❌ Token exchange failed. Please try again.")
335
+ return None
336
+
337
+ if not quiet:
338
+ print("✓ Claude Code authentication successful!")
339
+
340
+ return tokens
341
+
342
+
343
+ def get_token_storage_path() -> Path:
344
+ """Get path for storing OAuth tokens."""
345
+ return Path.home() / ".gac.env"
346
+
347
+
348
+ def load_stored_token() -> str | None:
349
+ """Load stored access token from .gac.env."""
350
+ from dotenv import dotenv_values
351
+
352
+ env_path = get_token_storage_path()
353
+ if not env_path.exists():
354
+ return None
355
+
356
+ env_vars = dotenv_values(str(env_path))
357
+ return env_vars.get("CLAUDE_CODE_ACCESS_TOKEN")
358
+
359
+
360
+ def save_token(access_token: str) -> bool:
361
+ """Save access token to .gac.env and update environment."""
362
+ import os
363
+
364
+ from dotenv import set_key
365
+
366
+ env_path = get_token_storage_path()
367
+ try:
368
+ set_key(str(env_path), "CLAUDE_CODE_ACCESS_TOKEN", access_token)
369
+ # Also update the current environment so the token is immediately available
370
+ os.environ["CLAUDE_CODE_ACCESS_TOKEN"] = access_token
371
+ return True
372
+ except Exception as exc:
373
+ logger.error("Failed to save token: %s", exc)
374
+ return False
375
+
376
+
377
+ def authenticate_and_save(quiet: bool = False) -> bool:
378
+ """Perform OAuth flow and save token."""
379
+ tokens = perform_oauth_flow(quiet=quiet)
380
+ if not tokens:
381
+ return False
382
+
383
+ access_token = tokens.get("access_token")
384
+ if not access_token:
385
+ if not quiet:
386
+ print("❌ No access token returned from authentication")
387
+ return False
388
+
389
+ if not save_token(access_token):
390
+ if not quiet:
391
+ print("❌ Failed to save access token")
392
+ return False
393
+
394
+ if not quiet:
395
+ print(f"✓ Access token saved to {get_token_storage_path()}")
396
+
397
+ return True
gac/providers/__init__.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from .anthropic import call_anthropic_api
4
4
  from .cerebras import call_cerebras_api
5
5
  from .chutes import call_chutes_api
6
+ from .claude_code import call_claude_code_api
6
7
  from .custom_anthropic import call_custom_anthropic_api
7
8
  from .custom_openai import call_custom_openai_api
8
9
  from .deepseek import call_deepseek_api
@@ -24,6 +25,7 @@ __all__ = [
24
25
  "call_anthropic_api",
25
26
  "call_cerebras_api",
26
27
  "call_chutes_api",
28
+ "call_claude_code_api",
27
29
  "call_custom_anthropic_api",
28
30
  "call_custom_openai_api",
29
31
  "call_deepseek_api",
@@ -0,0 +1,102 @@
1
+ """Claude Code provider implementation.
2
+
3
+ This provider allows users with Claude Code subscriptions to use their OAuth tokens
4
+ instead of paying for the expensive Anthropic API.
5
+ """
6
+
7
+ import os
8
+
9
+ import httpx
10
+
11
+ from gac.errors import AIError
12
+
13
+
14
+ def call_claude_code_api(model: str, messages: list[dict], temperature: float, max_tokens: int) -> str:
15
+ """Call Claude Code API using OAuth token.
16
+
17
+ This provider uses the Claude Code subscription OAuth token instead of the Anthropic API key.
18
+ It authenticates using Bearer token authentication with the special anthropic-beta header.
19
+
20
+ Environment variables:
21
+ CLAUDE_CODE_ACCESS_TOKEN: OAuth access token from Claude Code authentication
22
+
23
+ Args:
24
+ model: Model name (e.g., 'claude-sonnet-4-5')
25
+ messages: List of message dictionaries with 'role' and 'content' keys
26
+ temperature: Sampling temperature (0.0-1.0)
27
+ max_tokens: Maximum tokens in response
28
+
29
+ Returns:
30
+ Generated text response
31
+
32
+ Raises:
33
+ AIError: If authentication fails or API call fails
34
+ """
35
+ access_token = os.getenv("CLAUDE_CODE_ACCESS_TOKEN")
36
+ if not access_token:
37
+ raise AIError.authentication_error(
38
+ "CLAUDE_CODE_ACCESS_TOKEN not found in environment variables. "
39
+ "Please authenticate with Claude Code and set this token."
40
+ )
41
+
42
+ url = "https://api.anthropic.com/v1/messages"
43
+ headers = {
44
+ "Authorization": f"Bearer {access_token}",
45
+ "anthropic-version": "2023-06-01",
46
+ "anthropic-beta": "oauth-2025-04-20",
47
+ "content-type": "application/json",
48
+ }
49
+
50
+ # Convert messages to Anthropic format
51
+ # IMPORTANT: Claude Code OAuth tokens require the system message to be EXACTLY
52
+ # "You are Claude Code, Anthropic's official CLI for Claude." with NO additional content.
53
+ # Any other instructions must be moved to the user message.
54
+ anthropic_messages = []
55
+ system_instructions = ""
56
+
57
+ for msg in messages:
58
+ if msg["role"] == "system":
59
+ system_instructions = msg["content"]
60
+ else:
61
+ anthropic_messages.append({"role": msg["role"], "content": msg["content"]})
62
+
63
+ # Claude Code requires this exact system message, nothing more
64
+ system_message = "You are Claude Code, Anthropic's official CLI for Claude."
65
+
66
+ # Move any system instructions into the first user message
67
+ if system_instructions and anthropic_messages:
68
+ # Prepend system instructions to the first user message
69
+ first_user_msg = anthropic_messages[0]
70
+ first_user_msg["content"] = f"{system_instructions}\n\n{first_user_msg['content']}"
71
+
72
+ data = {
73
+ "model": model,
74
+ "messages": anthropic_messages,
75
+ "temperature": temperature,
76
+ "max_tokens": max_tokens,
77
+ "system": system_message,
78
+ }
79
+
80
+ try:
81
+ response = httpx.post(url, headers=headers, json=data, timeout=120)
82
+ response.raise_for_status()
83
+ response_data = response.json()
84
+ content = response_data["content"][0]["text"]
85
+ if content is None:
86
+ raise AIError.model_error("Claude Code API returned null content")
87
+ if content == "":
88
+ raise AIError.model_error("Claude Code API returned empty content")
89
+ return content
90
+ except httpx.HTTPStatusError as e:
91
+ if e.response.status_code == 401:
92
+ raise AIError.authentication_error(
93
+ f"Claude Code authentication failed: {e.response.text}. "
94
+ "Your token may have expired. Please re-authenticate."
95
+ ) from e
96
+ if e.response.status_code == 429:
97
+ raise AIError.rate_limit_error(f"Claude Code API rate limit exceeded: {e.response.text}") from e
98
+ raise AIError.model_error(f"Claude Code API error: {e.response.status_code} - {e.response.text}") from e
99
+ except httpx.TimeoutException as e:
100
+ raise AIError.timeout_error(f"Claude Code API request timed out: {str(e)}") from e
101
+ except Exception as e:
102
+ raise AIError.model_error(f"Error calling Claude Code API: {str(e)}") from e
@@ -30,7 +30,7 @@ def call_custom_anthropic_api(model: str, messages: list[dict], temperature: flo
30
30
  CUSTOM_ANTHROPIC_VERSION: API version header (optional, defaults to '2023-06-01')
31
31
 
32
32
  Args:
33
- model: The model to use (e.g., 'claude-3-5-sonnet-20241022', 'claude-3-5-haiku-latest')
33
+ model: The model to use (e.g., 'claude-sonnet-4-5', 'claude-haiku-4-5')
34
34
  messages: List of message dictionaries with 'role' and 'content' keys
35
35
  temperature: Controls randomness (0.0-1.0)
36
36
  max_tokens: Maximum tokens in the response
gac/utils.py CHANGED
@@ -1,7 +1,9 @@
1
1
  """Utility functions for gac."""
2
2
 
3
+ import locale
3
4
  import logging
4
5
  import subprocess
6
+ import sys
5
7
 
6
8
  from rich.console import Console
7
9
  from rich.theme import Theme
@@ -65,18 +67,47 @@ def print_message(message: str, level: str = "info") -> None:
65
67
  console.print(message, style=level)
66
68
 
67
69
 
68
- def run_subprocess(
70
+ def get_safe_encodings() -> list[str]:
71
+ """Get a list of safe encodings to try for subprocess calls, in order of preference.
72
+
73
+ Returns:
74
+ List of encoding strings to try, with UTF-8 first
75
+ """
76
+ encodings = ["utf-8"]
77
+
78
+ # Add locale encoding as fallback
79
+ locale_encoding = locale.getpreferredencoding(False)
80
+ if locale_encoding and locale_encoding not in encodings:
81
+ encodings.append(locale_encoding)
82
+
83
+ # Windows-specific fallbacks
84
+ if sys.platform == "win32":
85
+ windows_encodings = ["cp65001", "cp936", "cp1252"] # UTF-8, GBK, Windows-1252
86
+ for enc in windows_encodings:
87
+ if enc not in encodings:
88
+ encodings.append(enc)
89
+
90
+ # Final fallback to system default
91
+ if "utf-8" not in encodings:
92
+ encodings.append("utf-8")
93
+
94
+ return encodings
95
+
96
+
97
+ def run_subprocess_with_encoding(
69
98
  command: list[str],
99
+ encoding: str,
70
100
  silent: bool = False,
71
101
  timeout: int = 60,
72
102
  check: bool = True,
73
103
  strip_output: bool = True,
74
104
  raise_on_error: bool = True,
75
105
  ) -> str:
76
- """Run a subprocess command safely and return the output.
106
+ """Run subprocess with a specific encoding, handling encoding errors gracefully.
77
107
 
78
108
  Args:
79
109
  command: List of command arguments
110
+ encoding: Specific encoding to use
80
111
  silent: If True, suppress debug logging
81
112
  timeout: Command timeout in seconds
82
113
  check: Whether to check return code (for compatibility)
@@ -91,7 +122,7 @@ def run_subprocess(
91
122
  subprocess.CalledProcessError: If the command fails and raise_on_error is True
92
123
  """
93
124
  if not silent:
94
- logger.debug(f"Running command: {' '.join(command)}")
125
+ logger.debug(f"Running command: {' '.join(command)} (encoding: {encoding})")
95
126
 
96
127
  try:
97
128
  result = subprocess.run(
@@ -100,6 +131,8 @@ def run_subprocess(
100
131
  text=True,
101
132
  check=False,
102
133
  timeout=timeout,
134
+ encoding=encoding,
135
+ errors="replace", # Replace problematic characters instead of crashing
103
136
  )
104
137
 
105
138
  should_raise = result.returncode != 0 and (check or raise_on_error)
@@ -123,6 +156,11 @@ def run_subprocess(
123
156
  if raise_on_error:
124
157
  raise
125
158
  return ""
159
+ except UnicodeError as e:
160
+ # This should be rare with errors="replace", but handle it just in case
161
+ if not silent:
162
+ logger.debug(f"Encoding error with {encoding}: {e}")
163
+ raise
126
164
  except Exception as e:
127
165
  if not silent:
128
166
  logger.debug(f"Command error: {e}")
@@ -132,6 +170,69 @@ def run_subprocess(
132
170
  return ""
133
171
 
134
172
 
173
+ def run_subprocess(
174
+ command: list[str],
175
+ silent: bool = False,
176
+ timeout: int = 60,
177
+ check: bool = True,
178
+ strip_output: bool = True,
179
+ raise_on_error: bool = True,
180
+ ) -> str:
181
+ """Run a subprocess command safely and return the output, trying multiple encodings.
182
+
183
+ Args:
184
+ command: List of command arguments
185
+ silent: If True, suppress debug logging
186
+ timeout: Command timeout in seconds
187
+ check: Whether to check return code (for compatibility)
188
+ strip_output: Whether to strip whitespace from output
189
+ raise_on_error: Whether to raise an exception on error
190
+
191
+ Returns:
192
+ Command output as string
193
+
194
+ Raises:
195
+ GacError: If the command times out
196
+ subprocess.CalledProcessError: If the command fails and raise_on_error is True
197
+
198
+ Note:
199
+ Tries multiple encodings in order: utf-8, locale encoding, platform-specific fallbacks
200
+ This prevents UnicodeDecodeError on systems with non-UTF-8 locales (e.g., Chinese Windows)
201
+ """
202
+ encodings = get_safe_encodings()
203
+ last_exception = None
204
+
205
+ for encoding in encodings:
206
+ try:
207
+ return run_subprocess_with_encoding(
208
+ command=command,
209
+ encoding=encoding,
210
+ silent=silent,
211
+ timeout=timeout,
212
+ check=check,
213
+ strip_output=strip_output,
214
+ raise_on_error=raise_on_error,
215
+ )
216
+ except UnicodeError as e:
217
+ last_exception = e
218
+ if not silent:
219
+ logger.debug(f"Failed to decode with {encoding}: {e}")
220
+ continue
221
+ except (subprocess.CalledProcessError, GacError, subprocess.TimeoutExpired):
222
+ # These are not encoding-related errors, so don't retry with other encodings
223
+ raise
224
+
225
+ # If we get here, all encodings failed with UnicodeError
226
+ if not silent:
227
+ logger.error(f"Failed to decode command output with any encoding: {encodings}")
228
+
229
+ # Raise the last UnicodeError we encountered
230
+ if last_exception:
231
+ raise subprocess.CalledProcessError(1, command, "", f"Encoding error: {last_exception}") from last_exception
232
+ else:
233
+ raise subprocess.CalledProcessError(1, command, "", "All encoding attempts failed")
234
+
235
+
135
236
  def edit_commit_message_inplace(message: str) -> str | None:
136
237
  """Edit commit message in-place using rich terminal editing.
137
238