multi-claude-cli 0.4.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,195 @@
1
+ """会话交接:把一条 Claude 会话从一个账号复制到另一个账号(方案 feature-session-handoff)。
2
+
3
+ Claude 把会话存成 `<配置目录>/projects/<编码后的工作目录>/<会话ID>.jsonl`,旁边的同名目录
4
+ 放子代理记录与存成文件的大段工具输出。目录名编码是“非字母数字字符换成 -”,超过 200 字符时
5
+ 截断再接一段 Claude 内部的哈希;本模块不复刻那段哈希,目标账号直接沿用来源账号里已有的目录名。
6
+
7
+ 只复制,不移动、不删除来源,也不改写会话内容;目标里已有不同内容的同 ID 会话时默认拒绝。
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import shutil
13
+ import tempfile
14
+ from typing import Dict, List, NamedTuple, Optional, Tuple
15
+
16
+ from . import statusline
17
+ from .fsutil import build_manifest, copy_tree, diff_manifests
18
+
19
+ # Claude 2.1.286 的 `tx`:编码后的目录名超过这个长度就截断并接哈希。
20
+ ENCODED_LIMIT = 200
21
+ SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
22
+
23
+ COPIED = "copied"
24
+ UNCHANGED = "unchanged"
25
+ CONFLICT = "conflict"
26
+
27
+
28
+ class HandoffError(Exception):
29
+ """找不到目录或会话、复制失败等,对应退出码 1。"""
30
+
31
+
32
+ class Plan(NamedTuple):
33
+ session_id: str
34
+ # 找到会话目录时用的工作目录写法;续聊命令必须 cd 到这个写法,目标账号才会按同一目录名找会话
35
+ cwd: str
36
+ # 来源账号里的项目目录;目标账号沿用它的目录名
37
+ source_dir: str
38
+ target_dir: str
39
+ # 截到最后一个换行后的会话内容
40
+ content: bytes
41
+ # copied(将要 / 已经复制)、unchanged 或 conflict
42
+ state: str
43
+
44
+
45
+ def encode(path: str) -> str:
46
+ return re.sub(r"[^a-zA-Z0-9]", "-", path)
47
+
48
+
49
+ def cwd_variants(environ: Dict[str, str]) -> List[str]:
50
+ """Claude 编码时用的是物理路径还是 $PWD 没有核实,两种都试;$PWD 必须与当前目录是同一个目录。"""
51
+ variants = [os.getcwd()]
52
+ pwd = environ.get("PWD")
53
+ if pwd and os.path.isabs(pwd) and pwd not in variants:
54
+ try:
55
+ if os.path.samefile(pwd, variants[0]):
56
+ variants.append(pwd)
57
+ except OSError:
58
+ pass
59
+ return variants
60
+
61
+
62
+ def project_dir(account_dir: str, variants: List[str]) -> Tuple[str, str]:
63
+ """在账号的 projects/ 下找当前工作目录对应的目录,返回 (目录, 命中的工作目录写法);没有或有多个时抛 HandoffError。"""
64
+ projects = os.path.join(account_dir, "projects")
65
+ try:
66
+ names = os.listdir(projects)
67
+ except OSError:
68
+ names = []
69
+ found: List[Tuple[str, str]] = []
70
+ for variant in variants:
71
+ encoded = encode(variant)
72
+ if len(encoded) <= ENCODED_LIMIT:
73
+ matches = [encoded] if encoded in names else []
74
+ else:
75
+ # 超长路径的目录名后缀是 Claude 内部哈希,只能按截断后的前缀找。
76
+ prefix = encoded[:ENCODED_LIMIT] + "-"
77
+ matches = [name for name in names if name.startswith(prefix)]
78
+ for name in matches:
79
+ path = os.path.join(projects, name)
80
+ if os.path.isdir(path) and path not in [item[0] for item in found]:
81
+ found.append((path, variant))
82
+ if not found:
83
+ raise HandoffError("no sessions for {} in {}".format(variants[0], projects))
84
+ if len(found) > 1:
85
+ raise HandoffError("several session directories match {}: {}".format(
86
+ variants[0], ", ".join(item[0] for item in found)))
87
+ return found[0]
88
+
89
+
90
+ def pick_session(directory: str, requested: Optional[str], current: Optional[str]) -> str:
91
+ """选会话:--session 优先;其次当前会话(仅当它在这个目录里);最后取修改时间最新的一条。"""
92
+ if requested is not None:
93
+ if not os.path.isfile(os.path.join(directory, requested + ".jsonl")):
94
+ raise HandoffError("session {} not found in {}".format(requested, directory))
95
+ return requested
96
+ if current and SESSION_ID_PATTERN.match(current) and os.path.isfile(os.path.join(directory, current + ".jsonl")):
97
+ return current
98
+ candidates = [name[:-len(".jsonl")] for name in os.listdir(directory)
99
+ if name.endswith(".jsonl") and os.path.isfile(os.path.join(directory, name))]
100
+ if not candidates:
101
+ raise HandoffError("no sessions in {}".format(directory))
102
+ return max(candidates, key=lambda sid: os.stat(os.path.join(directory, sid + ".jsonl")).st_mtime_ns)
103
+
104
+
105
+ def plan(source_account_dir: str, target_account_dir: str, variants: List[str],
106
+ requested: Optional[str], current: Optional[str]) -> Plan:
107
+ source_dir, cwd = project_dir(source_account_dir, variants)
108
+ session_id = pick_session(source_dir, requested, current)
109
+ with open(os.path.join(source_dir, session_id + ".jsonl"), "rb") as handle:
110
+ raw = handle.read()
111
+ # 来源会话可能正在追加,只取到最后一个完整行,避免目标里出现半行 JSON。
112
+ end = raw.rfind(b"\n")
113
+ if end < 0:
114
+ raise HandoffError("session {} has no complete lines yet".format(session_id))
115
+ content = raw[:end + 1]
116
+ target_dir = os.path.join(target_account_dir, "projects", os.path.basename(source_dir))
117
+ return Plan(session_id, cwd, source_dir, target_dir, content,
118
+ _state(source_dir, target_dir, session_id, content))
119
+
120
+
121
+ def _state(source_dir: str, target_dir: str, session_id: str, content: bytes) -> str:
122
+ target_file = os.path.join(target_dir, session_id + ".jsonl")
123
+ target_extra = os.path.join(target_dir, session_id)
124
+ if not os.path.lexists(target_file) and not os.path.lexists(target_extra):
125
+ return COPIED
126
+ try:
127
+ with open(target_file, "rb") as handle:
128
+ same_file = handle.read() == content
129
+ except OSError:
130
+ same_file = False
131
+ return UNCHANGED if same_file and _same_extra(os.path.join(source_dir, session_id), target_extra) else CONFLICT
132
+
133
+
134
+ def _same_extra(source: str, target: str) -> bool:
135
+ has_source, has_target = os.path.isdir(source), os.path.lexists(target)
136
+ if not has_source or not has_target:
137
+ return has_source == has_target
138
+ if not os.path.isdir(target) or os.path.islink(target):
139
+ return False
140
+ return not diff_manifests(build_manifest(source)[0], build_manifest(target)[0])
141
+
142
+
143
+ def execute(handoff: Plan, force: bool) -> List[str]:
144
+ """按计划复制,返回 --force 时生成的备份路径。
145
+
146
+ 同名目录先落位、会话文件最后落位:会话文件出现就说明这次复制是完整的。
147
+ 任一步失败时删除本次的临时项;已落位的目录保留,再次运行时会参与冲突判定,不会被静默覆盖。
148
+ """
149
+ session_id = handoff.session_id
150
+ target_file = os.path.join(handoff.target_dir, session_id + ".jsonl")
151
+ target_extra = os.path.join(handoff.target_dir, session_id)
152
+ backups = []
153
+ if handoff.state == CONFLICT:
154
+ if not force:
155
+ raise RuntimeError("conflict must be handled by the caller")
156
+ # 备份名不以 .jsonl 结尾,Claude 不会把它当成会话列出来。
157
+ for path in (target_file, target_extra):
158
+ if os.path.lexists(path):
159
+ backup = statusline._backup_path(path)
160
+ os.rename(path, backup)
161
+ backups.append(backup)
162
+ os.makedirs(handoff.target_dir, exist_ok=True)
163
+ source_extra = os.path.join(handoff.source_dir, session_id)
164
+ if os.path.isdir(source_extra):
165
+ staging = os.path.join(handoff.target_dir, ".multi-claude-{}.tmp".format(session_id))
166
+ # 上次复制中途被杀会留下临时目录,它从未落位、不属于任何会话,直接清掉再复制。
167
+ _remove_tree(staging)
168
+ try:
169
+ copy_tree(source_extra, staging)
170
+ os.rename(staging, target_extra)
171
+ except BaseException:
172
+ _remove_tree(staging)
173
+ raise
174
+ fd, tmp_path = tempfile.mkstemp(prefix=".multi-claude-{}.".format(session_id), suffix=".jsonl.tmp",
175
+ dir=handoff.target_dir)
176
+ try:
177
+ with os.fdopen(fd, "wb") as handle:
178
+ handle.write(handoff.content)
179
+ handle.flush()
180
+ os.fsync(handle.fileno())
181
+ # 会话里可能有提示词与代码,与 Claude 自己写的会话文件一样只给属主读写。
182
+ os.chmod(tmp_path, 0o600)
183
+ os.replace(tmp_path, target_file)
184
+ except BaseException:
185
+ try:
186
+ os.unlink(tmp_path)
187
+ except FileNotFoundError:
188
+ pass
189
+ raise
190
+ return backups
191
+
192
+
193
+ def _remove_tree(path: str) -> None:
194
+ if os.path.lexists(path):
195
+ shutil.rmtree(path, ignore_errors=True)
multi_claude/shared.py ADDED
@@ -0,0 +1,130 @@
1
+ """共享资源链接的计划(方案 §5.1.10,沿用 multi-codex §5.1.6)。
2
+
3
+ 每个账号可以把 CLAUDE.md、skills 等条目软链到同一个共享目录。
4
+ 判断“是不是本工具建的链接”只看 Account.managed_links:链接本身带不了受管标记,
5
+ 而用户很可能早就手工建过指向同一共享目录的链接,这类链接关闭共享时不能删。
6
+ """
7
+
8
+ import os
9
+ from typing import List, Optional
10
+
11
+ from . import platform
12
+ from .actions import CONFLICT, CREATE, DELETE, SKIP, UNCHANGED, UPDATE, Action
13
+ from .config import Account, Config
14
+ from .fsutil import KIND_LINK, KIND_MISSING, entry_kind, expand, same_target
15
+
16
+
17
+ def plan_shared(new: Config, account: Account, account_dir: str,
18
+ old_shared_dir: Optional[str], adopt: bool = False) -> List[Action]:
19
+ """为一个账号生成共享链接动作,并就地更新 account.managed_links。
20
+
21
+ adopt 为真时(`add --adopt`),已经指向对应共享条目、但不在名单里的软链会被写进名单(接管),
22
+ 链接本身不动。只接管“指向正确”的软链:指向别处的软链和真实目录照旧判冲突,
23
+ 以免把用户另有用途的东西交给工具,之后关闭共享时被删掉。
24
+
25
+ old_shared_dir 是变更前的共享目录:修改 shared.dir 后,
26
+ 仍指向旧目录的受管链接需要改指向新目录,而不是被当成“指向别处”的冲突。
27
+ """
28
+ actions: List[Action] = []
29
+ desired = {}
30
+ if account.shared:
31
+ if not new.shared_dir:
32
+ return [Action(CONFLICT, "shared-link", account_dir,
33
+ "shared.dir is not configured; run `multi-claude init --shared-dir DIR`")]
34
+ shared_root = expand(new.shared_dir)
35
+ for item in new.shared_items:
36
+ if item in account.shared_exclude:
37
+ # 不进 desired:本工具建过的这项链接会走下面“该项关闭共享”的删除分支,用户自建的不动。
38
+ continue
39
+ source = os.path.join(shared_root, item)
40
+ if entry_kind(source) == KIND_MISSING:
41
+ actions.append(Action(SKIP, "shared-link", os.path.join(account_dir, item),
42
+ "not present in shared dir {}".format(shared_root), quiet=True))
43
+ continue
44
+ desired[item] = source
45
+
46
+ managed = set(account.managed_links)
47
+ old_root = expand(old_shared_dir) if old_shared_dir else None
48
+ kept: List[str] = []
49
+
50
+ for item, source in desired.items():
51
+ link = os.path.join(account_dir, item)
52
+ kind = entry_kind(link)
53
+ if kind == KIND_MISSING:
54
+ actions.append(Action(CREATE, "shared-link", link, "-> " + source,
55
+ _create_link(source, link)))
56
+ kept.append(item)
57
+ elif kind == KIND_LINK and same_target(link, source):
58
+ if item in managed:
59
+ actions.append(Action(UNCHANGED, "shared-link", link))
60
+ kept.append(item)
61
+ elif adopt:
62
+ # 没有文件操作:接管只体现在 config.json 的 managed_links 里。
63
+ actions.append(Action(UPDATE, "shared-link", link, "adopted"))
64
+ kept.append(item)
65
+ else:
66
+ actions.append(Action(UNCHANGED, "shared-link", link))
67
+ elif kind == KIND_LINK and item in managed and _points_to(link, old_root, item):
68
+ actions.append(Action(UPDATE, "shared-link", link, "-> " + source,
69
+ _replace_link(source, link)))
70
+ kept.append(item)
71
+ else:
72
+ reason = "a link to another location" if kind == KIND_LINK else "a real {}".format(
73
+ "directory" if kind == "dir" else "file")
74
+ actions.append(Action(CONFLICT, "shared-link", link,
75
+ "already exists as {}".format(reason)))
76
+
77
+ for item in account.managed_links:
78
+ if item in desired:
79
+ continue
80
+ link = os.path.join(account_dir, item)
81
+ kind = entry_kind(link)
82
+ if kind == KIND_MISSING:
83
+ continue
84
+ if kind == KIND_LINK and (_points_to(link, old_root, item) or
85
+ _points_to(link, expand(new.shared_dir) if new.shared_dir else None, item)):
86
+ actions.append(Action(DELETE, "shared-link", link, "sharing disabled for this item",
87
+ _unlink(link)))
88
+ else:
89
+ # 用户已把它换成了别的东西,不再归本工具管理;从名单里移除,但不动它。
90
+ actions.append(Action(SKIP, "shared-link", link,
91
+ "no longer the link multi-claude created; left untouched"))
92
+
93
+ # 保持与 shared_items 相同的顺序,便于人工阅读 config.json。
94
+ order = {item: index for index, item in enumerate(new.shared_items)}
95
+ account.managed_links = sorted(set(kept), key=lambda item: order.get(item, len(order)))
96
+ return actions
97
+
98
+
99
+ def plan_remove_links(account: Account, account_dir: str, shared_dir: Optional[str]) -> List[Action]:
100
+ """账号被注销时,删除本工具建立、且仍指向共享目录的链接。"""
101
+ actions: List[Action] = []
102
+ root = expand(shared_dir) if shared_dir else None
103
+ for item in account.managed_links:
104
+ link = os.path.join(account_dir, item)
105
+ if entry_kind(link) == KIND_LINK and _points_to(link, root, item):
106
+ actions.append(Action(DELETE, "shared-link", link, "account removed", _unlink(link)))
107
+ return actions
108
+
109
+
110
+ def _points_to(link: str, shared_root: Optional[str], item: str) -> bool:
111
+ return shared_root is not None and same_target(link, os.path.join(shared_root, item))
112
+
113
+
114
+ def _create_link(source: str, link: str):
115
+ def run() -> None:
116
+ platform.create_link(source, link)
117
+ return run
118
+
119
+
120
+ def _replace_link(source: str, link: str):
121
+ def run() -> None:
122
+ os.unlink(link)
123
+ platform.create_link(source, link)
124
+ return run
125
+
126
+
127
+ def _unlink(link: str):
128
+ def run() -> None:
129
+ os.unlink(link)
130
+ return run
@@ -0,0 +1,33 @@
1
+ """生成“把目录加进 PATH”的一行命令(方案 feature-clearer-help §5.1.3)。
2
+
3
+ 只生成给用户看的提示文字,不改任何 shell 配置文件。按 $SHELL 选择配置文件:
4
+ macOS 的 bash 登录 shell 读 ~/.bash_profile,其它系统的交互 bash 读 ~/.bashrc;fish 用 fish_add_path。
5
+ install.sh 里有同一张表的 shell 实现,两边要保持一致。
6
+ """
7
+
8
+ import os
9
+ from typing import Optional
10
+
11
+
12
+ def add_to_path_command(directory: str, shell: Optional[str], system: str) -> Optional[str]:
13
+ """返回可直接粘贴运行的命令;不认识的 shell、或目录里有单引号(无法安全写进单引号串)时返回 None。"""
14
+ if "'" in directory:
15
+ return None
16
+ name = os.path.basename(shell or "")
17
+ export_line = "export PATH=\"{}:$PATH\"".format(directory)
18
+ if name == "zsh":
19
+ return "echo '{}' >> ~/.zshrc".format(export_line)
20
+ if name == "bash":
21
+ profile = "~/.bash_profile" if system == "Darwin" else "~/.bashrc"
22
+ return "echo '{}' >> {}".format(export_line, profile)
23
+ if name == "fish":
24
+ return "fish_add_path '{}'".format(directory)
25
+ return None
26
+
27
+
28
+ def path_hint(directory: str, shell: Optional[str], system: str) -> str:
29
+ """提示用户把 directory 加进 PATH 的完整一句话(cli 与 doctor 共用)。"""
30
+ command = add_to_path_command(directory, shell, system)
31
+ if command is None:
32
+ return "add it to PATH in your shell profile"
33
+ return "run: {}, then open a new terminal".format(command)
@@ -0,0 +1,196 @@
1
+ """statusLine 包装与用量采集(方案 feature-statusline-usage)。
2
+
3
+ `statusline install FILE` 把设置文件里已有的 statusLine 命令包成
4
+ `<multi-claude 路径> statusline-hook <原命令>`。Claude Code 刷新状态栏时用 `/bin/sh -c` 执行它,
5
+ 并把一段 JSON 写进 stdin。钩子入口在 hook.py:它把 stdin 交给后台进程里的 capture,
6
+ 自己用 exec 把进程交给 `/bin/sh -c <原命令>`,状态栏的输出、退出码与不包装时完全相同。
7
+
8
+ 钩子靠环境里的 `CLAUDE_CONFIG_DIR` 认账号(default 身份不设它),不读凭据、不联网。
9
+ 钩子的 stdout 就是状态栏内容,所以采集过程中不得向 stdout 输出任何东西,采集失败也只能静默。
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import shlex
15
+ import shutil
16
+ import stat
17
+ import time
18
+ from datetime import datetime, timedelta
19
+ from typing import Mapping, NamedTuple, Optional, Tuple
20
+
21
+ from . import accounts, usage
22
+ from .config import Account, Config, load_config
23
+ from .fsutil import atomic_write
24
+ from .hook import HOOK_COMMAND
25
+
26
+ EXECUTABLE = "multi-claude"
27
+ # 同一账号的状态栏约 300 ms 刷新一次;数值没变时一分钟内只落盘一次。
28
+ THROTTLE = timedelta(seconds=60)
29
+
30
+ # install / uninstall 的结果
31
+ WRAPPED = "wrapped"
32
+ REWRAPPED = "rewrapped"
33
+ UNCHANGED = "unchanged"
34
+ RESTORED = "restored"
35
+ NOT_INSTALLED = "not installed"
36
+
37
+
38
+ class StatuslineError(Exception):
39
+ """设置文件或其中的 statusLine 不合法,对应退出码 1;文件未被修改。"""
40
+
41
+
42
+ class Result(NamedTuple):
43
+ state: str
44
+ # 实际改写的文件(FILE 是软链时为它指向的文件)
45
+ target: str
46
+ # 改写前的备份;没有写入(无变化或 --dry-run)时为 None
47
+ backup: Optional[str]
48
+ # 改写后的 statusLine 命令
49
+ command: str
50
+
51
+
52
+ def wrap(executable: str, original: str) -> str:
53
+ """包装后的命令。两段都用 shlex.quote:/bin/sh 对单引号串不做任何展开,钩子收到的参数与原命令逐字节相同。"""
54
+ return "{} {} {}".format(shlex.quote(executable), HOOK_COMMAND, shlex.quote(original))
55
+
56
+
57
+ def unwrap(command: str) -> Optional[Tuple[str, str]]:
58
+ """若 command 是本工具包装过的命令,返回 (multi-claude 路径, 原命令);否则返回 None。"""
59
+ try:
60
+ parts = shlex.split(command)
61
+ except ValueError:
62
+ return None
63
+ if len(parts) == 3 and parts[1] == HOOK_COMMAND and os.path.basename(parts[0]) == EXECUTABLE:
64
+ return parts[0], parts[2]
65
+ return None
66
+
67
+
68
+ def install(path: str, executable: str, dry_run: bool = False) -> Result:
69
+ """包装 path 中的 statusLine 命令;已包装且路径相同时不改动,路径变了则改用新路径。"""
70
+ target, data, command = _load(path)
71
+ wrapped = unwrap(command)
72
+ if wrapped is not None and wrapped[0] == executable:
73
+ return Result(UNCHANGED, target, None, command)
74
+ original = wrapped[1] if wrapped is not None else command
75
+ new_command = wrap(executable, original)
76
+ state = REWRAPPED if wrapped is not None else WRAPPED
77
+ return Result(state, target, _save(target, data, new_command, dry_run), new_command)
78
+
79
+
80
+ def uninstall(path: str, dry_run: bool = False) -> Result:
81
+ """把 path 中包装过的 statusLine 命令还原成原命令;没有包装时不改动。"""
82
+ target, data, command = _load(path)
83
+ wrapped = unwrap(command)
84
+ if wrapped is None:
85
+ return Result(NOT_INSTALLED, target, None, command)
86
+ return Result(RESTORED, target, _save(target, data, wrapped[1], dry_run), wrapped[1])
87
+
88
+
89
+ def _load(path: str) -> Tuple[str, dict, str]:
90
+ # 改软链指向的文件而不是替换软链本身:~/.claude-shared 下的设置文件常被各账号软链引用。
91
+ target = os.path.realpath(path)
92
+ try:
93
+ with open(target, "r", encoding="utf-8") as handle:
94
+ raw = handle.read()
95
+ except FileNotFoundError:
96
+ raise StatuslineError("{} does not exist".format(target))
97
+ except (OSError, UnicodeDecodeError) as exc:
98
+ raise StatuslineError("cannot read {}: {}".format(target, exc))
99
+ try:
100
+ data = json.loads(raw)
101
+ except ValueError as exc:
102
+ raise StatuslineError("{} is not valid JSON: {}".format(target, exc))
103
+ if not isinstance(data, dict):
104
+ raise StatuslineError("{} is not a JSON object".format(target))
105
+ status_line = data.get("statusLine")
106
+ if (not isinstance(status_line, dict) or status_line.get("type") != "command"
107
+ or not isinstance(status_line.get("command"), str) or not status_line["command"].strip()):
108
+ raise StatuslineError("no command statusLine in {}".format(target))
109
+ return target, data, status_line["command"]
110
+
111
+
112
+ def _save(target: str, data: dict, command: str, dry_run: bool) -> Optional[str]:
113
+ """先备份再原子写入;只改 statusLine.command,其它键(padding、refreshInterval 等)原样保留。"""
114
+ if dry_run:
115
+ return None
116
+ data["statusLine"]["command"] = command
117
+ mode = stat.S_IMODE(os.stat(target).st_mode)
118
+ backup = _backup_path(target)
119
+ shutil.copy2(target, backup)
120
+ atomic_write(target, json.dumps(data, indent=2, ensure_ascii=False) + "\n", mode=mode)
121
+ return backup
122
+
123
+
124
+ def _backup_path(target: str) -> str:
125
+ base = "{}.multi-claude-bak.{}".format(target, time.strftime("%Y%m%d_%H%M%S"))
126
+ candidate, index = base, 1
127
+ # 同一秒内连续执行两次时不覆盖前一份备份。
128
+ while os.path.lexists(candidate):
129
+ candidate = "{}.{}".format(base, index)
130
+ index += 1
131
+ return candidate
132
+
133
+
134
+ def capture(data: bytes, environ: Mapping[str, str], now: datetime) -> None:
135
+ """把 stdin JSON 里的 rate_limits 存成所属账号的快照;没有数据、认不出账号时什么也不做。"""
136
+ payload = json.loads(data.decode("utf-8"))
137
+ if not isinstance(payload, dict):
138
+ return
139
+ windows = usage.statusline_windows(payload.get("rate_limits"))
140
+ if windows is None:
141
+ # 非 Pro/Max 订阅、或会话还没收到第一次 API 响应时没有 rate_limits。
142
+ return
143
+ config, exists = load_config()
144
+ if not exists:
145
+ return
146
+ account = account_for(config, environ.get("CLAUDE_CONFIG_DIR"))
147
+ if account is None:
148
+ return
149
+ path = usage.snapshot_path(account)
150
+ if _recently_captured(path, windows, now):
151
+ return
152
+ directory = accounts.account_dir(config, account.name)
153
+ document = {
154
+ "schema_version": usage.SNAPSHOT_SCHEMA_VERSION,
155
+ "captured_at": now.isoformat(),
156
+ # 读取时与账号当前目录比较,目录被换掉后旧快照不再采用。
157
+ "config_dir": os.path.realpath(directory),
158
+ # 读取时与当前登录比较,同一目录换了登录后旧快照不再采用。
159
+ "account_uuid": usage.current_account_uuid(usage.global_state_path(account.identity, directory)),
160
+ "rate_limits": windows,
161
+ }
162
+ os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
163
+ # 同一账号的多个会话会同时写:原子替换保证读到的总是某一次完整的快照。
164
+ atomic_write(path, json.dumps(document, indent=2) + "\n", mode=0o600)
165
+
166
+
167
+ def account_for(config: Config, config_dir: Optional[str]) -> Optional[Account]:
168
+ """按 Claude 进程的 CLAUDE_CONFIG_DIR 找账号;未设置时就是 default 身份账号(它靠不设该变量使用 ~/.claude)。
169
+
170
+ 比较真实路径:经软链或带 `~` 的写法指向同一目录也能认出。
171
+ """
172
+ if not config_dir:
173
+ return config.default_account()
174
+ real = os.path.realpath(os.path.expanduser(config_dir))
175
+ for account in config.accounts.values():
176
+ if os.path.realpath(accounts.account_dir(config, account.name)) == real:
177
+ return account
178
+ return None
179
+
180
+
181
+ def _recently_captured(path: str, windows: dict, now: datetime) -> bool:
182
+ try:
183
+ with open(path, "r", encoding="utf-8") as handle:
184
+ previous = json.load(handle)
185
+ except (OSError, ValueError):
186
+ return False
187
+ if not isinstance(previous, dict) or previous.get("rate_limits") != windows:
188
+ return False
189
+ try:
190
+ captured_at = datetime.fromisoformat(previous.get("captured_at"))
191
+ except (TypeError, ValueError):
192
+ return False
193
+ if captured_at.tzinfo is None:
194
+ return False
195
+ # 快照时间在未来(时钟回拨)时不算“刚写过”,重新写一次把时间纠正回来。
196
+ return timedelta(0) <= now - captured_at < THROTTLE