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,3 @@
1
+ """multi-claude:管理多个 Claude Code 账号的配置目录、启动命令、代理与启动参数。"""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,12 @@
1
+ import sys
2
+
3
+ if sys.argv[1:2] == ["statusline-hook"]:
4
+ # 状态栏每次刷新都会执行它:只导入轻量的 hook,exec 之前不加载 cli 与其它模块。
5
+ # 这个字面量必须与 hook.HOOK_COMMAND 一致(测试断言);这里不导入 hook 来比较,
6
+ # 是为了让其它命令连 hook 也不加载。
7
+ from .hook import main as hook_main
8
+ sys.exit(hook_main(sys.argv[2:]))
9
+
10
+ from .cli import main # noqa: E402
11
+
12
+ sys.exit(main())
@@ -0,0 +1,434 @@
1
+ """账号收敛引擎:对比新旧配置与实际文件状态,生成并执行动作列表(方案 §5.1.1、§5.1.2)。
2
+
3
+ 所有写命令(init、add、proxy、env、args、remove、apply,以及迁移最后的登记步骤)都走这里:
4
+ 1. 调用方算出新配置;
5
+ 2. plan() 只读地生成完整动作列表,并就地更新新配置里的 managed_links;
6
+ 3. 有冲突就直接返回 3,什么都不写;
7
+ 4. 否则先原子写入 config.json,再逐个执行文件动作。
8
+
9
+ 先写配置再执行文件动作,是为了让“文件动作中途失败”可以靠重跑收敛:
10
+ 配置已经是新的,下次 plan() 会补齐缺的文件。反过来先改文件再写配置,
11
+ 失败时会留下配置里没有记录的链接,之后再也不会被清理。
12
+
13
+ 与 multi-codex 相比多了两条冲突规则(方案 §5.1.8):dir 身份账号的启动命令里那串路径
14
+ 一经生成就不得被改变(macOS 登录绑定在它上面);大小写不敏感的文件系统上,账号名必须与
15
+ 磁盘上的目录名逐字相同。
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from typing import FrozenSet, Iterable, List, Optional, Set, Tuple, Union
21
+
22
+ from . import launcher, platform, routes, shared
23
+ from .actions import (CONFLICT, CREATE, DELETE, SKIP, UNCHANGED, UPDATE, Action, error,
24
+ has_conflict, info, print_action, warn)
25
+ from .config import (IDENTITY_DEFAULT, PROXY_INHERIT, PROXY_VARS, Account, Config, config_path,
26
+ dump_config, save_config)
27
+ from .fsutil import KIND_DIR, KIND_MISSING, atomic_write, entry_kind, expand, read_text
28
+
29
+ EXIT_OK = 0
30
+ EXIT_ERROR = 1
31
+ EXIT_USAGE = 2
32
+ EXIT_CONFLICT = 3
33
+ EXIT_BUSY = 4
34
+
35
+ # orphan_scope 取这个值时,清理启动命令目录里所有不在配置中的受管启动命令。
36
+ ALL_ORPHANS = "all"
37
+ NO_ORPHANS: FrozenSet[str] = frozenset()
38
+
39
+
40
+ def account_dir(config: Config, name: str) -> str:
41
+ """账号目录路径字符串:只展开 `~` 并转绝对路径,不做 realpath。
42
+
43
+ dir 身份账号的 macOS 钥匙串服务名由这串字符决定,与 Claude 的 we() 一样不能解析软链。
44
+ 目录名取账号的 dir(改过名的账号与账号名不同);name 未登记时按 name 计算(新建账号时)。
45
+ """
46
+ account = config.find(name)
47
+ return os.path.join(expand(config.root), account.dir if account is not None else name)
48
+
49
+
50
+ def default_dir() -> str:
51
+ """不设 CLAUDE_CONFIG_DIR 时 Claude 使用的配置目录($HOME/.claude 展开后)。"""
52
+ return expand(platform.default_source())
53
+
54
+
55
+ def render_launcher(config: Config, account: Account) -> str:
56
+ return launcher.render(account.name, account.identity, account_dir(config, account.name), default_dir(),
57
+ account.proxy, config.effective_env(account), config.effective_args(account))
58
+
59
+
60
+ def plan(old: Config, new: Config, *, config_exists: bool = True,
61
+ orphan_scope: Union[str, FrozenSet[str]] = NO_ORPHANS,
62
+ assume_dirs: Iterable[str] = (), adopt_accounts: FrozenSet[str] = frozenset()) -> List[Action]:
63
+ """生成从 old 收敛到 new 所需的全部动作。只读文件系统,不做任何修改。
64
+
65
+ orphan_scope:要清理的孤儿启动命令的账号名集合(casefold 后比较);ALL_ORPHANS 表示全部清理,
66
+ NO_ORPHANS 表示不清理。只有 apply 和 remove 需要清理孤儿。
67
+ assume_dirs:视为已存在的账号目录。迁移在移动数据前做预检时,目标目录还不存在,
68
+ 但迁移完成后它一定存在,不应计划“创建目录”。
69
+ adopt_accounts:要接管已有共享软链的账号名集合(casefold 后比较),只有 `add --adopt` 会传入。
70
+ """
71
+ actions: List[Action] = []
72
+ assumed = {expand(path) for path in assume_dirs}
73
+ old_root, new_root = expand(old.root), expand(new.root)
74
+ old_bin, new_bin = expand(old.bin_dir), expand(new.bin_dir)
75
+
76
+ # 工具不会搬迁账号目录(搬了 macOS 上就会失去登录),所以已有账号时改根目录只能判冲突。
77
+ if old.accounts and old_root != new_root:
78
+ actions.append(Action(CONFLICT, "config", config_path(),
79
+ "cannot change root from {} to {} while accounts are registered".format(
80
+ old_root, new_root)))
81
+
82
+ planned_deletes: Set[str] = set()
83
+ for account in new.accounts.values():
84
+ old_account = old.find(account.name)
85
+ if old_account is not None and old_account.name != account.name:
86
+ # 只改了大小写:在区分大小写的文件系统上会新建另一份目录和启动命令,
87
+ # 而旧启动命令因为按大小写不敏感匹配,既不算孤儿也不会被清理。
88
+ actions.append(Action(CONFLICT, "config", config_path(),
89
+ "account {!r} is registered as {!r}; renaming is not supported".format(
90
+ account.name, old_account.name)))
91
+ continue
92
+ directory = os.path.join(new_root, account.dir)
93
+ actions.extend(_plan_dir_owner_conflict(new, account))
94
+ actions.extend(_plan_account_dir(directory, account, assumed))
95
+ actions.extend(_plan_launcher(new_bin, new, account))
96
+ if old_account is not None and old_bin != new_bin:
97
+ actions.extend(_plan_launcher_delete(old_bin, old_account.name, planned_deletes,
98
+ "bin_dir changed"))
99
+ actions.extend(shared.plan_shared(new, account, directory, old.shared_dir,
100
+ adopt=account.name.casefold() in adopt_accounts))
101
+
102
+ for old_account in old.accounts.values():
103
+ if new.find(old_account.name) is None:
104
+ actions.extend(_plan_launcher_delete(old_bin, old_account.name, planned_deletes,
105
+ "account removed"))
106
+ # 改名时新账号沿用同一个目录:共享软链仍在使用,不能当成“账号移除”删掉。
107
+ if not any(other.dir.casefold() == old_account.dir.casefold() for other in new.accounts.values()):
108
+ actions.extend(shared.plan_remove_links(old_account, os.path.join(old_root, old_account.dir),
109
+ old.shared_dir))
110
+
111
+ if orphan_scope:
112
+ for name, path in launcher.scan_managed(new_bin).items():
113
+ if new.find(name) is not None:
114
+ continue
115
+ if orphan_scope == ALL_ORPHANS or name.casefold() in orphan_scope:
116
+ actions.extend(_plan_launcher_delete(new_bin, name, planned_deletes,
117
+ "orphan launcher", path=path))
118
+
119
+ actions.extend(_plan_router(old, new))
120
+
121
+ config_changed = not config_exists or dump_config(old) != dump_config(new)
122
+ config_action = Action(CREATE if not config_exists else (UPDATE if config_changed else UNCHANGED),
123
+ "config", config_path())
124
+ return [config_action] + actions
125
+
126
+
127
+ def _plan_dir_owner_conflict(config: Config, account: Account) -> List[Action]:
128
+ """与 config._check_dirs 同一套规则,在计划阶段判冲突,保证写出的配置下次仍能加载:
129
+ ① 两个账号用同一个目录(典型:`rename work job` 之后再 `add work`,新账号会落进 job 的目录);
130
+ ② 账号名等于另一个账号的目录(典型:job 的目录是 work 时 `rename main work`)。"""
131
+ for other in config.accounts.values():
132
+ if other is account:
133
+ continue
134
+ if other.dir.casefold() == account.dir.casefold():
135
+ return [Action(CONFLICT, "config", config_path(),
136
+ "account {!r} would use the directory of account {!r}".format(account.name, other.name))]
137
+ if other.dir.casefold() == account.name.casefold():
138
+ return [Action(CONFLICT, "config", config_path(),
139
+ "account name {!r} is the directory of account {!r}".format(account.name, other.name))]
140
+ return []
141
+
142
+
143
+ def _plan_router(old: Config, new: Config) -> List[Action]:
144
+ """路由入口 claude-auto 的动作与冲突(方案 feature-directory-routing §5.1.3)。
145
+
146
+ 只认路由标记:账号启动命令、用户自己的同名文件一律不覆盖、不删除。
147
+ """
148
+ actions: List[Action] = []
149
+ new_path = routes.router_path(new.bin_dir)
150
+ old_path = routes.router_path(old.bin_dir)
151
+ if old_path != new_path and routes.is_router(old_path):
152
+ actions.append(Action(DELETE, "router", old_path, "bin_dir changed", lambda: os.unlink(old_path)))
153
+ if not new.routes_enabled:
154
+ if routes.is_router(new_path):
155
+ actions.append(Action(DELETE, "router", new_path, "no routes configured", lambda: os.unlink(new_path)))
156
+ return actions
157
+
158
+ referenced = [(rule.account, "route {} uses account {!r}, which is not registered; run "
159
+ "`multi-claude route {} --remove` first".format(rule.path, rule.account, rule.path))
160
+ for rule in new.route_rules]
161
+ if new.route_default is not None:
162
+ referenced.append((new.route_default, "the default route uses account {!r}, which is not registered; "
163
+ "run `multi-claude route --no-default` first".format(new.route_default)))
164
+ for name, reason in referenced:
165
+ if new.find(name) is None:
166
+ actions.append(Action(CONFLICT, "config", config_path(), reason))
167
+ if new.find(routes.RESERVED_ACCOUNT) is not None:
168
+ # 账号 auto 的启动命令也叫 claude-auto,两者必然互相覆盖。
169
+ actions.append(Action(CONFLICT, "router", new_path, "claude-auto is the directory router; an account "
170
+ "named 'auto' cannot coexist with routes"))
171
+
172
+ content = routes.render_router(new)
173
+ kind = entry_kind(new_path)
174
+ if kind == KIND_MISSING:
175
+ actions.append(Action(CREATE, "router", new_path, run=_write_launcher(new_path, content)))
176
+ elif not routes.is_router(new_path):
177
+ actions.append(Action(CONFLICT, "router", new_path, "already exists and is not the multi-claude router"))
178
+ elif read_text(new_path) == content and os.access(new_path, os.X_OK):
179
+ actions.append(Action(UNCHANGED, "router", new_path))
180
+ else:
181
+ actions.append(Action(UPDATE, "router", new_path, run=_write_launcher(new_path, content)))
182
+ return actions
183
+
184
+
185
+ def _plan_account_dir(directory: str, account: Account, assumed: Set[str]) -> List[Action]:
186
+ if directory in assumed:
187
+ return []
188
+ kind = entry_kind(directory)
189
+ if kind == KIND_MISSING:
190
+ if account.identity == IDENTITY_DEFAULT:
191
+ # default 身份的目录是迁移搬过来的原 ~/.claude,新建一个空目录只会让启动命令落到空账号。
192
+ return [Action(CONFLICT, "account-dir", directory,
193
+ "the directory of the default-identity account is missing")]
194
+ return [Action(CREATE, "account-dir", directory, run=lambda: _make_private_dir(directory))]
195
+ # 账号目录本身是软链(例如用户手工迁移后留的链接)也可以接受,只要最终指向目录。
196
+ if kind == KIND_DIR or os.path.isdir(directory):
197
+ actual = _actual_entry_name(directory)
198
+ if actual is not None and actual != account.dir:
199
+ # 大小写不敏感的文件系统上,`add work` 会命中磁盘上的 `Work/`;启动命令却会写成 `work`,
200
+ # 路径字符串变了,macOS 上的登录(按字符串哈希)随之失效。
201
+ return [Action(CONFLICT, "account-dir", directory,
202
+ "directory exists as {!r} (differs only in case); the launcher would use {!r}, "
203
+ "which is not the path you logged in with; register it as {!r}".format(
204
+ actual, account.dir, actual))]
205
+ return [Action(UNCHANGED, "account-dir", directory)]
206
+ return [Action(CONFLICT, "account-dir", directory, "exists but is not a directory")]
207
+
208
+
209
+ def _actual_entry_name(directory: str) -> Optional[str]:
210
+ """isdir 为真但父目录里没有逐字相同的名字时,返回磁盘上的实际名称;否则返回 None。
211
+
212
+ 大小写敏感的文件系统上,`Work/` 与 `work/` 可以并存,`work` 能在 listdir 中逐字找到,不受影响。
213
+ """
214
+ parent, name = os.path.split(directory)
215
+ try:
216
+ entries = os.listdir(parent)
217
+ except OSError:
218
+ return None
219
+ if name in entries:
220
+ return None
221
+ for entry in entries:
222
+ if entry.casefold() == name.casefold():
223
+ return entry
224
+ return None
225
+
226
+
227
+ def _make_private_dir(directory: str) -> None:
228
+ # 目录里会保存 .credentials.json(Linux)和会话记录,所以账号目录和根目录都只允许本人访问。
229
+ parent = os.path.dirname(directory)
230
+ if not os.path.isdir(parent):
231
+ os.makedirs(parent, mode=0o700)
232
+ os.mkdir(directory, 0o700)
233
+ os.chmod(directory, 0o700)
234
+
235
+
236
+ def _plan_launcher(bin_dir: str, config: Config, account: Account) -> List[Action]:
237
+ name = account.name
238
+ path = launcher.launcher_path(bin_dir, name)
239
+ content = render_launcher(config, account)
240
+ kind = entry_kind(path)
241
+ if kind == KIND_MISSING:
242
+ return [Action(CREATE, "launcher", path, run=_write_launcher(path, content))]
243
+ owner = launcher.managed_account(path)
244
+ if owner is None:
245
+ return [Action(CONFLICT, "launcher", path, "already exists and is not managed by multi-claude")]
246
+ if owner.casefold() != name.casefold():
247
+ return [Action(CONFLICT, "launcher", path, "managed by multi-claude for account {!r}".format(owner))]
248
+ current = read_text(path)
249
+ if current == content and os.access(path, os.X_OK):
250
+ return [Action(UNCHANGED, "launcher", path)]
251
+ if account.identity != IDENTITY_DEFAULT and current is not None:
252
+ old_dir = launcher.rendered_account_dir(current)
253
+ new_dir = account_dir(config, name)
254
+ if old_dir is not None and old_dir != new_dir:
255
+ # 典型原因:HOME 变了而状态目录与 bin_dir 写的是绝对路径。改写这一行会让 macOS 上的
256
+ # 钥匙串服务名变掉,账号随之“掉登录”,所以只能交给用户决定(方案 §5.1.8)。
257
+ return [Action(CONFLICT, "launcher", path,
258
+ "would change the login-bound account path ({} -> {})".format(old_dir, new_dir))]
259
+ return [Action(UPDATE, "launcher", path, run=_write_launcher(path, content))]
260
+
261
+
262
+ def _write_launcher(path: str, content: str):
263
+ def run() -> None:
264
+ atomic_write(path, content, mode=launcher.LAUNCHER_MODE)
265
+ return run
266
+
267
+
268
+ def _plan_launcher_delete(bin_dir: str, name: str, planned: Set[str], reason: str,
269
+ path: Optional[str] = None) -> List[Action]:
270
+ path = path or launcher.launcher_path(bin_dir, name)
271
+ if path in planned or entry_kind(path) == KIND_MISSING:
272
+ return []
273
+ owner = launcher.managed_account(path)
274
+ if owner is None or owner.casefold() != name.casefold():
275
+ return [Action(SKIP, "launcher", path, "not managed by multi-claude for this account; left untouched")]
276
+ planned.add(path)
277
+ return [Action(DELETE, "launcher", path, reason, lambda: os.unlink(path))]
278
+
279
+
280
+ def execute(old: Config, new: Config, actions: List[Action], *, dry_run: bool, verbose: bool = False) -> int:
281
+ """按 §5.1.1 的顺序执行:有冲突则全部不写;否则先写配置,再逐个执行文件动作。
282
+
283
+ 默认不打印 unchanged 的动作:一条命令会把所有账号的每一项都列出来,新用户分不清哪些是这次的改动。
284
+ verbose 为真时列出全部(方案 feature-easier-onboarding §5.1.5)。
285
+ """
286
+ printed = False
287
+ for action in actions:
288
+ if (action.status == UNCHANGED or action.quiet) and not verbose:
289
+ continue
290
+ print_action(action, dry_run=dry_run)
291
+ printed = True
292
+ if not printed:
293
+ info("nothing to change")
294
+ if has_conflict(actions):
295
+ info("nothing was changed because of the conflicts above")
296
+ return EXIT_CONFLICT
297
+ if dry_run:
298
+ return EXIT_OK
299
+ config_action = actions[0]
300
+ if config_action.changes:
301
+ try:
302
+ save_config(new)
303
+ except OSError as exc:
304
+ error(str(exc), phase="config", path=config_action.path)
305
+ return EXIT_ERROR
306
+ for action in actions[1:]:
307
+ if not action.changes or action.run is None:
308
+ continue
309
+ try:
310
+ action.run()
311
+ except OSError as exc:
312
+ # 配置已经写入,重跑同一命令或 `apply` 会从这里继续收敛。
313
+ error("{}; rerun the command or `multi-claude apply` to finish".format(exc),
314
+ phase=action.kind, path=action.path)
315
+ return EXIT_ERROR
316
+ return EXIT_OK
317
+
318
+
319
+ def converge(old: Config, new: Config, *, config_exists: bool, dry_run: bool,
320
+ orphan_scope: Union[str, FrozenSet[str]] = NO_ORPHANS, assume_dirs: Iterable[str] = (),
321
+ adopt_accounts: FrozenSet[str] = frozenset(), verbose: bool = False) -> int:
322
+ actions = plan(old, new, config_exists=config_exists, orphan_scope=orphan_scope,
323
+ assume_dirs=assume_dirs, adopt_accounts=adopt_accounts)
324
+ return execute(old, new, actions, dry_run=dry_run, verbose=verbose)
325
+
326
+
327
+ def launcher_status(config: Config, name: str) -> str:
328
+ """list 命令用:ok、missing、conflict 或 stale(内容与配置不一致)。"""
329
+ account = config.find(name)
330
+ path = launcher.launcher_path(expand(config.bin_dir), name)
331
+ if entry_kind(path) == KIND_MISSING:
332
+ return "missing"
333
+ owner = launcher.managed_account(path)
334
+ if owner is None or owner.casefold() != name.casefold() or account is None:
335
+ return "conflict"
336
+ expected = render_launcher(config, account)
337
+ return "ok" if read_text(path) == expected and os.access(path, os.X_OK) else "stale"
338
+
339
+
340
+ def default_link_status(config: Config, account: Account) -> str:
341
+ """default 身份账号的 ~/.claude 软链状态:ok、broken(指向别处或断链)或 missing。"""
342
+ source = default_dir()
343
+ kind = entry_kind(source)
344
+ if kind == KIND_MISSING:
345
+ return "missing"
346
+ target = account_dir(config, account.name)
347
+ if os.path.isdir(source) and os.path.isdir(target) and os.path.realpath(source) == os.path.realpath(target):
348
+ return "ok"
349
+ return "broken"
350
+
351
+
352
+ # ---- 只提示、不改变退出码的检查(方案 §5.1.3、§5.1.5) ----
353
+
354
+ def warn_launch_settings(config: Config, names: Optional[Iterable[str]] = None, *,
355
+ note_invalid: bool = False) -> None:
356
+ """对指定账号(None 表示全部)输出可变参数告警与“settings 覆盖代理”告警。
357
+
358
+ note_invalid 为真时(list 命令),另外注明哪些 settings 文件因不是合法 JSON 而没有检查。
359
+ """
360
+ selected = list(config.accounts.values()) if names is None else [
361
+ account for account in (config.find(name) for name in names) if account is not None]
362
+ for account in selected:
363
+ message = launcher.variadic_warning(config.effective_args(account))
364
+ if message:
365
+ warn("claude-{}: {}".format(account.name, message))
366
+ if account.proxy == PROXY_INHERIT:
367
+ continue
368
+ invalid: List[str] = []
369
+ for path, key in settings_proxy_overrides(config, account, invalid):
370
+ warn("{} sets {} in env, which overrides the proxy set by claude-{}".format(path, key, account.name))
371
+ if note_invalid:
372
+ for path in invalid:
373
+ info("{} is not valid JSON; not checked for proxy overrides of claude-{}".format(
374
+ path, account.name))
375
+
376
+
377
+ def settings_proxy_overrides(config: Config, account: Account,
378
+ invalid: Optional[List[str]] = None) -> List[Tuple[str, str]]:
379
+ """列出会覆盖启动命令代理的 settings 来源:(文件或 'inline --settings', 变量名)。
380
+
381
+ Claude 会把 .claude.json、各级 settings(含 --settings 指定的文件)的 env 写进进程环境,
382
+ 同名变量以 settings 为准(方案 §3 依据 13)。这里只读顶层 env 的键名,不读、不输出值;
383
+ 文件不存在或不是合法 JSON 时跳过,后者记入 invalid(调用方传入时)。托管设置的位置由管理员决定,不检查。
384
+ """
385
+ directory = account_dir(config, account.name)
386
+ global_config = (os.path.join(os.path.expanduser("~"), ".claude.json")
387
+ if account.identity == IDENTITY_DEFAULT else os.path.join(directory, ".claude.json"))
388
+ sources: List[Tuple[str, Optional[str]]] = [
389
+ (os.path.join(directory, "settings.json"), None),
390
+ (global_config, None),
391
+ ]
392
+ args = config.effective_args(account)
393
+ for index, arg in enumerate(args):
394
+ value = None
395
+ if arg == "--settings" and index + 1 < len(args):
396
+ value = args[index + 1]
397
+ elif arg.startswith("--settings="):
398
+ value = arg[len("--settings="):]
399
+ if value is None:
400
+ continue
401
+ if value.lstrip().startswith("{"):
402
+ sources.append(("inline --settings", value))
403
+ else:
404
+ sources.append((launcher.expand_value(value), None))
405
+ proxy_keys = {var.upper() for var in PROXY_VARS}
406
+ found: List[Tuple[str, str]] = []
407
+ for label, inline in sources:
408
+ env = _settings_env_keys(label, inline)
409
+ if env is None:
410
+ if invalid is not None:
411
+ invalid.append(label)
412
+ continue
413
+ for key in env:
414
+ if key.upper() in proxy_keys:
415
+ found.append((label, key))
416
+ return found
417
+
418
+
419
+ def _settings_env_keys(path: str, inline: Optional[str]) -> Optional[List[str]]:
420
+ """返回顶层 env 的键名;文件不存在返回空列表,存在但不是合法 JSON 返回 None。"""
421
+ try:
422
+ if inline is not None:
423
+ data = json.loads(inline)
424
+ else:
425
+ with open(path, "r", encoding="utf-8") as handle:
426
+ data = json.load(handle)
427
+ except OSError:
428
+ return []
429
+ except ValueError:
430
+ return None
431
+ env = data.get("env") if isinstance(data, dict) else None
432
+ if not isinstance(env, dict):
433
+ return []
434
+ return [key for key in env if isinstance(key, str)]
@@ -0,0 +1,66 @@
1
+ """动作列表:计划阶段产出、执行阶段逐个执行的最小单元,以及统一的输出格式。
2
+
3
+ 所有写命令都先生成完整的动作列表,只要其中有一个 conflict 就什么都不写(方案 §5.1.1),
4
+ 所以“计划”与“执行”必须分开:计划阶段只读文件系统,副作用全部放在 run 回调里。
5
+ """
6
+
7
+ import sys
8
+ from typing import Callable, List, Optional
9
+
10
+ CREATE = "create"
11
+ UPDATE = "update"
12
+ DELETE = "delete"
13
+ UNCHANGED = "unchanged"
14
+ CONFLICT = "conflict"
15
+ # 不需要改动、但值得告诉用户的情况,例如共享目录里没有某个条目。
16
+ SKIP = "skip"
17
+
18
+ LOG_PREFIX = "[multi-claude]"
19
+
20
+
21
+ class Action(object):
22
+ def __init__(self, status: str, kind: str, path: str, reason: str = "",
23
+ run: Optional[Callable[[], None]] = None, quiet: bool = False) -> None:
24
+ self.status = status
25
+ # 对象类型:config、account-dir、launcher、shared-link、backup 等
26
+ self.kind = kind
27
+ self.path = path
28
+ self.reason = reason
29
+ self.run = run
30
+ # 不改变任何东西、又会在每条命令里重复出现的提示(如共享目录里缺某一项):默认不打印,--verbose 时打印。
31
+ self.quiet = quiet
32
+
33
+ @property
34
+ def changes(self) -> bool:
35
+ return self.status in (CREATE, UPDATE, DELETE)
36
+
37
+
38
+ def has_conflict(actions: List[Action]) -> bool:
39
+ return any(action.status == CONFLICT for action in actions)
40
+
41
+
42
+ def print_action(action: Action, dry_run: bool = False) -> None:
43
+ status = action.status + (" (dry-run)" if dry_run and action.changes else "")
44
+ line = "{} {} {} {}".format(LOG_PREFIX, status, action.kind, action.path)
45
+ if action.reason:
46
+ line += " ({})".format(action.reason)
47
+ stream = sys.stderr if action.status == CONFLICT else sys.stdout
48
+ print(line, file=stream)
49
+
50
+
51
+ def info(message: str) -> None:
52
+ print("{} {}".format(LOG_PREFIX, message))
53
+
54
+
55
+ def warn(message: str) -> None:
56
+ print("{} warning: {}".format(LOG_PREFIX, message), file=sys.stderr)
57
+
58
+
59
+ def error(message: str, phase: Optional[str] = None, path: Optional[str] = None) -> None:
60
+ """错误输出:带阶段和路径,让用户不必翻代码就能知道失败在哪一步、哪个文件。"""
61
+ parts = [LOG_PREFIX, "error"]
62
+ if phase:
63
+ parts.append("phase={}".format(phase))
64
+ if path:
65
+ parts.append("path={}".format(path))
66
+ print("{}: {}".format(" ".join(parts), message), file=sys.stderr)