tsugite-claude-code 0.23.1__tar.gz → 0.23.2__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,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-claude-code
3
- Version: 0.23.1
3
+ Version: 0.23.2
4
4
  Summary: Tsugite plugin: Claude Code provider (routes LLM calls through the claude CLI)
5
5
  Author: Justyn Shull
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: tsugite-cli==0.23.1
7
+ Requires-Dist: tsugite-cli==0.23.2
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "tsugite-claude-code"
3
- version = "0.23.1"
3
+ version = "0.23.2"
4
4
  description = "Tsugite plugin: Claude Code provider (routes LLM calls through the claude CLI)"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
- "tsugite-cli==0.23.1",
8
+ "tsugite-cli==0.23.2",
9
9
  ]
10
10
 
11
11
  [project.entry-points."tsugite.providers"]
@@ -10,11 +10,13 @@ import asyncio
10
10
  import json
11
11
  import logging
12
12
  import os
13
+ import re
13
14
  import shutil
14
15
  import tempfile
15
16
  import time
16
17
  import uuid
17
18
  from collections.abc import AsyncIterator
19
+ from pathlib import Path
18
20
 
19
21
  from tsugite.cli.helpers import get_workspace_dir
20
22
 
@@ -38,6 +40,34 @@ _STREAM_READ_LIMIT = 16 * 1024 * 1024
38
40
  _STALL_WARN_SECONDS = 300.0
39
41
  _STALL_WARN_ENV = "TSUGITE_CLAUDE_CODE_STALL_WARN_SECONDS"
40
42
 
43
+ # The CLI reads `@path` in a user turn as its own file-mention syntax and injects that
44
+ # file itself, below tsugite's tool layer and outside `allowed_secrets`. A backslash
45
+ # blocks it. Escaping every `@` would mangle decorators, e-mail addresses and npm scopes.
46
+ _AT_TOKEN = re.compile(r"@(\S+)")
47
+ _MENTION_TRAILERS = ",.;:!?)]}>\"'"
48
+
49
+
50
+ def _resolves_to_path(candidate: str, cwd: Path) -> bool:
51
+ try:
52
+ return (cwd / candidate).exists()
53
+ except OSError:
54
+ return False # exists() raises ENAMETOOLONG rather than returning False.
55
+
56
+
57
+ def _escape_at_mentions(content: str | list, cwd: Path) -> str | list:
58
+ def escape(match: re.Match) -> str:
59
+ candidate = match.group(1).rstrip(_MENTION_TRAILERS)
60
+ if candidate and _resolves_to_path(candidate, cwd):
61
+ return "\\" + match.group(0)
62
+ return match.group(0)
63
+
64
+ if isinstance(content, str):
65
+ return _AT_TOKEN.sub(escape, content)
66
+ return [
67
+ {**b, "text": _AT_TOKEN.sub(escape, b["text"])} if isinstance(b, dict) and b.get("type") == "text" else b
68
+ for b in content
69
+ ]
70
+
41
71
 
42
72
  class ClaudeCodeProcess:
43
73
  """Manages a persistent claude CLI subprocess for LLM completions.
@@ -61,6 +91,7 @@ class ClaudeCodeProcess:
61
91
  else float(os.environ.get(_STALL_WARN_ENV, _STALL_WARN_SECONDS))
62
92
  )
63
93
  self._last_output_at: float = 0.0
94
+ self._cwd: Path | None = None
64
95
 
65
96
  @property
66
97
  def session_id(self) -> str | None:
@@ -181,6 +212,7 @@ class ClaudeCodeProcess:
181
212
  env["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] = "1"
182
213
 
183
214
  workspace = get_workspace_dir()
215
+ self._cwd = workspace
184
216
  self._process = await asyncio.create_subprocess_exec(
185
217
  *cmd,
186
218
  stdin=asyncio.subprocess.PIPE,
@@ -207,8 +239,8 @@ class ClaudeCodeProcess:
207
239
 
208
240
  Args:
209
241
  content: User message text, or an Anthropic content-block list
210
- (text + image blocks) for a multimodal turn. Forwarded to the
211
- CLI's stdin verbatim -- the CLI relays blocks to the API.
242
+ (text + image blocks) for a multimodal turn. The CLI relays
243
+ blocks to the API.
212
244
 
213
245
  Yields:
214
246
  Dicts with type "text_delta" (streaming chunk) or "result" (final)
@@ -216,6 +248,7 @@ class ClaudeCodeProcess:
216
248
  Raises:
217
249
  RuntimeError: If subprocess has crashed
218
250
  """
251
+ content = _escape_at_mentions(content, self._cwd or Path.cwd())
219
252
  msg = {
220
253
  "type": "user",
221
254
  "message": {"role": "user", "content": content},
@@ -0,0 +1,106 @@
1
+ """Claude Code subscription limits as a tsugite quota source.
2
+
3
+ Read from ``limits[]`` in the usage endpoint Claude Code calls, with its OAuth
4
+ token. When that fails, the usage copy cached in Claude Code's config is used
5
+ while it is under an hour old. The list is self-describing, so a limit kind
6
+ tsugite has never seen still shows up.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+
19
+ from tsugite.usage.quota import QuotaReport, QuotaWindow, register_quota_source
20
+ from tsugite.user_agent import set_user_agent_header
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ PROVIDER = "claude_code"
25
+ LABEL = "Claude Code"
26
+ USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
27
+ CACHE_MAX_AGE_MS = 60 * 60 * 1000
28
+ LIVE_TTL_S = 60
29
+
30
+ _last_live: tuple[float, list] | None = None
31
+
32
+
33
+ def _config_path() -> Path:
34
+ base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~")
35
+ return Path(base) / ".claude.json"
36
+
37
+
38
+ def _credentials_path() -> Path:
39
+ base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude")
40
+ return Path(base) / ".credentials.json"
41
+
42
+
43
+ def _live_limits() -> list:
44
+ global _last_live
45
+ if _last_live and time.monotonic() - _last_live[0] < LIVE_TTL_S:
46
+ return _last_live[1]
47
+ token = json.loads(_credentials_path().read_text())["claudeAiOauth"]["accessToken"]
48
+ headers = {"Authorization": f"Bearer {token}", "anthropic-beta": "oauth-2025-04-20"}
49
+ set_user_agent_header(headers)
50
+ response = httpx.get(USAGE_URL, headers=headers, timeout=10)
51
+ response.raise_for_status()
52
+ limits = response.json()["limits"]
53
+ _last_live = (time.monotonic(), limits)
54
+ return limits
55
+
56
+
57
+ def _cached_limits() -> list:
58
+ cache = json.loads(_config_path().read_text())["cachedUsageUtilization"]
59
+ if time.time() * 1000 - cache["fetchedAtMs"] > CACHE_MAX_AGE_MS:
60
+ raise ValueError("cached usage is over an hour old")
61
+ return cache["utilization"]["limits"]
62
+
63
+
64
+ def _label(limit: dict) -> str:
65
+ kind = limit["kind"]
66
+ if kind == "weekly_all":
67
+ return "week"
68
+ if kind == "weekly_scoped":
69
+ model = (limit.get("scope") or {}).get("model") or {}
70
+ name = model.get("display_name")
71
+ return f"week · {name}" if name else kind
72
+ return kind
73
+
74
+
75
+ def _error(message: str) -> QuotaReport:
76
+ return QuotaReport(provider=PROVIDER, label=LABEL, error=message)
77
+
78
+
79
+ def fetch() -> QuotaReport:
80
+ try:
81
+ limits = _live_limits()
82
+ except Exception as live_error:
83
+ try:
84
+ limits = _cached_limits()
85
+ except Exception as cache_error:
86
+ logger.warning("Claude Code usage unavailable: live %r, cache %r", live_error, cache_error)
87
+ return _error("Claude Code's usage limits could not be fetched, and no recent cached copy exists")
88
+
89
+ try:
90
+ windows = tuple(
91
+ QuotaWindow(
92
+ key=limit["kind"],
93
+ label=_label(limit),
94
+ used_pct=float(limit["percent"]),
95
+ resets_at=limit.get("resets_at"),
96
+ )
97
+ for limit in limits
98
+ if limit.get("percent") is not None
99
+ )
100
+ except Exception:
101
+ return _error("Claude Code's usage limits are not in a shape tsugite understands")
102
+
103
+ return QuotaReport(provider=PROVIDER, label=LABEL, windows=windows)
104
+
105
+
106
+ register_quota_source(PROVIDER, LABEL, fetch)
@@ -1,70 +0,0 @@
1
- """Claude Code subscription limits as a tsugite quota source.
2
-
3
- Read from ``utilization.limits[]`` in the usage cache Claude Code writes to its
4
- own config. That list is self-describing, so a limit kind tsugite has never seen
5
- still shows up. Its sibling keys hold an account id, and a spend figure that
6
- reads 100% while disabled; neither belongs in a report.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import os
13
- from pathlib import Path
14
-
15
- from tsugite.usage.quota import QuotaReport, QuotaWindow, register_quota_source
16
-
17
- PROVIDER = "claude_code"
18
- LABEL = "Claude Code"
19
-
20
-
21
- def _config_path() -> Path:
22
- base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~")
23
- return Path(base) / ".claude.json"
24
-
25
-
26
- def _label(limit: dict) -> str:
27
- kind = limit["kind"]
28
- if kind == "weekly_all":
29
- return "week"
30
- if kind == "weekly_scoped":
31
- model = (limit.get("scope") or {}).get("model") or {}
32
- name = model.get("display_name")
33
- return f"week · {name}" if name else kind
34
- return kind
35
-
36
-
37
- def _error(message: str) -> QuotaReport:
38
- return QuotaReport(provider=PROVIDER, label=LABEL, error=message)
39
-
40
-
41
- def fetch() -> QuotaReport:
42
- try:
43
- data = json.loads(_config_path().read_text())
44
- except Exception:
45
- return _error("Claude Code's config could not be read")
46
-
47
- cache = data.get("cachedUsageUtilization")
48
- if not cache:
49
- return _error("Claude Code has not cached its usage limits yet")
50
-
51
- try:
52
- # A window the cache has not filled in yet carries no percent; skip it
53
- # rather than discard the ones it has.
54
- windows = tuple(
55
- QuotaWindow(
56
- key=limit["kind"],
57
- label=_label(limit),
58
- used_pct=float(limit["percent"]),
59
- resets_at=limit.get("resets_at"),
60
- )
61
- for limit in cache["utilization"]["limits"]
62
- if limit.get("percent") is not None
63
- )
64
- except Exception:
65
- return _error("Claude Code's cached usage limits are not in a shape tsugite understands")
66
-
67
- return QuotaReport(provider=PROVIDER, label=LABEL, windows=windows)
68
-
69
-
70
- register_quota_source(PROVIDER, LABEL, fetch)