claude-session-sync-cli 0.1.2__tar.gz → 0.2.0__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.
@@ -4,6 +4,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.2.0] - 2026-11-11
8
+
9
+ ### Added
10
+ - Account names in the output: the account the CLI is signed in to is recognized from `~/.claude.json`, every `--apply` remembers it in `~/.claude-desktop-sync/accounts.json`, and `--set-alias <id>=<name>` names any account. Emails are always masked.
11
+ - `--account` accepts an ID prefix (such as the 8 characters shown in the output) and an optional `/<org-id-prefix>`; ambiguous prefixes are rejected.
12
+
13
+ ## [0.1.3] - 2026-11-11
14
+
15
+ ### Added
16
+ - `--lang en|zh` to pick the output language for a single run; it overrides `CLAUDE_SESSION_SYNC_LANG` and the locale.
17
+ - The English `--help` now shows a one-line hint in Chinese on how to switch, and the Chinese `--help` shows the English equivalent.
18
+
7
19
  ## [0.1.2] - 2026-11-11
8
20
 
9
21
  ### Fixed
@@ -26,7 +38,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
26
38
  - `--version` and `--help` with examples.
27
39
  - English and Chinese output, chosen from the locale; override with `CLAUDE_SESSION_SYNC_LANG=en|zh`.
28
40
 
29
- [Unreleased]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.2...HEAD
41
+ [Unreleased]: https://github.com/xushenjie/claude-session-sync/compare/v0.2.0...HEAD
42
+ [0.2.0]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.3...v0.2.0
43
+ [0.1.3]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.2...v0.1.3
30
44
  [0.1.2]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.1...v0.1.2
31
45
  [0.1.1]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.0...v0.1.1
32
46
  [0.1.0]: https://github.com/xushenjie/claude-session-sync/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: claude-session-sync-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Sync local Claude Code CLI sessions into the Claude desktop app sidebar (unofficial).
5
5
  Project-URL: Homepage, https://github.com/xushenjie/claude-session-sync
6
6
  Project-URL: Issues, https://github.com/xushenjie/claude-session-sync/issues
@@ -126,7 +126,7 @@ claude-session-sync --undo
126
126
 
127
127
  > If it says the desktop app is running, the app is still alive in the menu bar or system tray and **nothing was written**. Quit it fully and run again.
128
128
 
129
- Output is in English, or in Chinese when your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`, or the Windows display language) is Chinese. Set `CLAUDE_SESSION_SYNC_LANG=en` or `zh` to override.
129
+ Output is in English, or in Chinese when your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`, or the Windows display language) is Chinese. Pass `--lang en` or `--lang zh` for a single run, or set `CLAUDE_SESSION_SYNC_LANG=en` or `zh` in your shell profile to change it for good without touching your locale.
130
130
 
131
131
  ---
132
132
 
@@ -178,11 +178,13 @@ claude-session-sync --purge-archived --apply # do it
178
178
  |---|---|
179
179
  | `-h`, `--help` | Show help, examples, and caveats |
180
180
  | `-V`, `--version` | Show the version |
181
+ | `--lang en\|zh` | Output language; overrides `CLAUDE_SESSION_SYNC_LANG` and the locale |
181
182
  | (no options) | Preview mode |
182
183
  | `--apply` | Actually write |
183
184
  | `--undo` | Undo the most recent write or deletion |
184
185
  | `--show-template` | Print each account's template fields and exit (for troubleshooting) |
185
- | `--account <account-id>` | Only sync this account; repeatable |
186
+ | `--account <account-id>` | Only sync this account. A prefix such as the 8 characters in the output is enough; add `/<org-id-prefix>` to pick one organization. Repeatable |
187
+ | `--set-alias <id>=<name>` | Give an account a display name (see [Account names](https://github.com/xushenjie/claude-session-sync#account-names)); an empty name removes it. Repeatable; exits after saving |
186
188
  | `--project <string>` | Only handle sessions whose working directory contains this string (sync and purge) |
187
189
  | `--exclude <session-id>` | Exclude a session from sync and purge; repeatable |
188
190
  | `--exclude-file <file>` | Read excluded session IDs from a file, one per line |
@@ -203,7 +205,22 @@ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
203
205
  claude-session-sync --exclude-file ~/no-sync.txt --apply
204
206
  ```
205
207
 
206
- A session ID is the file name of a `.jsonl` under `~/.claude/projects/<project>/`. An account ID is a top-level directory name under `claude-code-sessions/`; the preview shows the first 8 characters.
208
+ A session ID is the file name of a `.jsonl` under `~/.claude/projects/<project>/`. An account ID is a top-level directory name under `claude-code-sessions/`; the preview shows the first 8 characters, which `--account` and `--set-alias` accept as-is.
209
+
210
+ ### Account names
211
+
212
+ The desktop app stores accounts by ID only, so the output shows names where it can find them:
213
+
214
+ ```
215
+ [work · Acme Inc · fan***@example.com · current CLI login] (b8073c71…/677f8bdf…) existing 122, to add 1
216
+ [07ac42e2…/00000000…] existing 119, to add 1
217
+ ```
218
+
219
+ - **The account the CLI is signed in to** is recognized from `~/.claude.json` (only the account and organization IDs, email and organization name are read).
220
+ - **Accounts the CLI was signed in to before**: every `--apply` remembers the current CLI account in `~/.claude-desktop-sync/accounts.json`, so it keeps its name after you switch.
221
+ - **Any account**, with an alias you choose: `claude-session-sync --set-alias b8073c71=work`. Use `<account>/<org>` prefixes to name a single organization.
222
+
223
+ Emails are always shown masked (`fan***@example.com`), because this output often ends up in bug reports. Previews never write `accounts.json`.
207
224
 
208
225
  ---
209
226
 
@@ -233,7 +250,7 @@ Use the package name `claude-session-sync-cli` (not the command name) with your
233
250
  Not sure which one you used? `uv tool list` or `pipx list` will show it.
234
251
 
235
252
  - **Upgrading** replaces the old version in place; the command name stays the same. Running `uv tool install` again does not upgrade — use `upgrade`. Journals and backups in `~/.claude-desktop-sync/` are kept, so `--undo` still works across upgrades. Run `claude-session-sync --version` to confirm.
236
- - **Uninstalling removes only the program.** Sessions it added to the desktop sidebar and everything in `~/.claude-desktop-sync/` stay. To revert those too, quit the desktop app and run `claude-session-sync --undo` repeatedly *before* uninstalling, until it reports nothing left to undo; then delete `~/.claude-desktop-sync/` if you no longer need the backups.
253
+ - **Uninstalling removes only the program.** Sessions it added to the desktop sidebar and everything in `~/.claude-desktop-sync/` stay. To revert those too, quit the desktop app and run `claude-session-sync --undo` repeatedly *before* uninstalling, until it reports nothing left to undo; then delete `~/.claude-desktop-sync/` if you no longer need the backups and saved account names.
237
254
 
238
255
  ---
239
256
 
@@ -97,7 +97,7 @@ claude-session-sync --undo
97
97
 
98
98
  > If it says the desktop app is running, the app is still alive in the menu bar or system tray and **nothing was written**. Quit it fully and run again.
99
99
 
100
- Output is in English, or in Chinese when your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`, or the Windows display language) is Chinese. Set `CLAUDE_SESSION_SYNC_LANG=en` or `zh` to override.
100
+ Output is in English, or in Chinese when your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`, or the Windows display language) is Chinese. Pass `--lang en` or `--lang zh` for a single run, or set `CLAUDE_SESSION_SYNC_LANG=en` or `zh` in your shell profile to change it for good without touching your locale.
101
101
 
102
102
  ---
103
103
 
@@ -149,11 +149,13 @@ claude-session-sync --purge-archived --apply # do it
149
149
  |---|---|
150
150
  | `-h`, `--help` | Show help, examples, and caveats |
151
151
  | `-V`, `--version` | Show the version |
152
+ | `--lang en\|zh` | Output language; overrides `CLAUDE_SESSION_SYNC_LANG` and the locale |
152
153
  | (no options) | Preview mode |
153
154
  | `--apply` | Actually write |
154
155
  | `--undo` | Undo the most recent write or deletion |
155
156
  | `--show-template` | Print each account's template fields and exit (for troubleshooting) |
156
- | `--account <account-id>` | Only sync this account; repeatable |
157
+ | `--account <account-id>` | Only sync this account. A prefix such as the 8 characters in the output is enough; add `/<org-id-prefix>` to pick one organization. Repeatable |
158
+ | `--set-alias <id>=<name>` | Give an account a display name (see [Account names](https://github.com/xushenjie/claude-session-sync#account-names)); an empty name removes it. Repeatable; exits after saving |
157
159
  | `--project <string>` | Only handle sessions whose working directory contains this string (sync and purge) |
158
160
  | `--exclude <session-id>` | Exclude a session from sync and purge; repeatable |
159
161
  | `--exclude-file <file>` | Read excluded session IDs from a file, one per line |
@@ -174,7 +176,22 @@ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
174
176
  claude-session-sync --exclude-file ~/no-sync.txt --apply
175
177
  ```
176
178
 
177
- A session ID is the file name of a `.jsonl` under `~/.claude/projects/<project>/`. An account ID is a top-level directory name under `claude-code-sessions/`; the preview shows the first 8 characters.
179
+ A session ID is the file name of a `.jsonl` under `~/.claude/projects/<project>/`. An account ID is a top-level directory name under `claude-code-sessions/`; the preview shows the first 8 characters, which `--account` and `--set-alias` accept as-is.
180
+
181
+ ### Account names
182
+
183
+ The desktop app stores accounts by ID only, so the output shows names where it can find them:
184
+
185
+ ```
186
+ [work · Acme Inc · fan***@example.com · current CLI login] (b8073c71…/677f8bdf…) existing 122, to add 1
187
+ [07ac42e2…/00000000…] existing 119, to add 1
188
+ ```
189
+
190
+ - **The account the CLI is signed in to** is recognized from `~/.claude.json` (only the account and organization IDs, email and organization name are read).
191
+ - **Accounts the CLI was signed in to before**: every `--apply` remembers the current CLI account in `~/.claude-desktop-sync/accounts.json`, so it keeps its name after you switch.
192
+ - **Any account**, with an alias you choose: `claude-session-sync --set-alias b8073c71=work`. Use `<account>/<org>` prefixes to name a single organization.
193
+
194
+ Emails are always shown masked (`fan***@example.com`), because this output often ends up in bug reports. Previews never write `accounts.json`.
178
195
 
179
196
  ---
180
197
 
@@ -204,7 +221,7 @@ Use the package name `claude-session-sync-cli` (not the command name) with your
204
221
  Not sure which one you used? `uv tool list` or `pipx list` will show it.
205
222
 
206
223
  - **Upgrading** replaces the old version in place; the command name stays the same. Running `uv tool install` again does not upgrade — use `upgrade`. Journals and backups in `~/.claude-desktop-sync/` are kept, so `--undo` still works across upgrades. Run `claude-session-sync --version` to confirm.
207
- - **Uninstalling removes only the program.** Sessions it added to the desktop sidebar and everything in `~/.claude-desktop-sync/` stay. To revert those too, quit the desktop app and run `claude-session-sync --undo` repeatedly *before* uninstalling, until it reports nothing left to undo; then delete `~/.claude-desktop-sync/` if you no longer need the backups.
224
+ - **Uninstalling removes only the program.** Sessions it added to the desktop sidebar and everything in `~/.claude-desktop-sync/` stay. To revert those too, quit the desktop app and run `claude-session-sync --undo` repeatedly *before* uninstalling, until it reports nothing left to undo; then delete `~/.claude-desktop-sync/` if you no longer need the backups and saved account names.
208
225
 
209
226
  ---
210
227
 
@@ -97,7 +97,7 @@ claude-session-sync --undo
97
97
 
98
98
  > 如果提示"检测到桌面端正在运行",说明桌面端还挂在菜单栏或托盘里,**这次什么都没有写入**。彻底退出后重新运行即可。
99
99
 
100
- 系统语言(`LC_ALL`、`LC_MESSAGES`、`LANG`,或 Windows 的显示语言)是中文时输出中文,否则输出英文。可以用 `CLAUDE_SESSION_SYNC_LANG=zh` 或 `en` 强制指定。
100
+ 系统语言(`LC_ALL`、`LC_MESSAGES`、`LANG`,或 Windows 的显示语言)是中文时输出中文,否则输出英文。临时切换加 `--lang zh` 或 `--lang en`;想长期使用中文,又不想改系统语言,在 shell 配置文件(如 `~/.zshrc`)里加上 `export CLAUDE_SESSION_SYNC_LANG=zh`。
101
101
 
102
102
  ---
103
103
 
@@ -149,11 +149,13 @@ claude-session-sync --purge-archived --apply # 执行
149
149
  |---|---|
150
150
  | `-h`, `--help` | 显示帮助、示例和注意事项 |
151
151
  | `-V`, `--version` | 显示版本号 |
152
+ | `--lang en\|zh` | 输出语言,优先于 `CLAUDE_SESSION_SYNC_LANG` 和系统语言 |
152
153
  | (不加参数) | 预览模式 |
153
154
  | `--apply` | 实际写入 |
154
155
  | `--undo` | 撤销最近一次写入或删除 |
155
156
  | `--show-template` | 显示每个账号的模板字段后退出(排查用) |
156
- | `--account <账号ID>` | 只同步指定账号,可以写多次 |
157
+ | `--account <账号ID>` | 只同步指定账号。写输出里显示的前 8 位就够了;加 `/<组织ID前缀>` 可以只选一个组织。可以写多次 |
158
+ | `--set-alias <ID>=<名称>` | 给账号起显示名称(见[账号名称](#账号名称)),名称留空则删除。可以写多次,保存后退出 |
157
159
  | `--project <字符串>` | 只处理工作目录里包含这个字符串的会话(同步和清除都生效) |
158
160
  | `--exclude <会话ID>` | 排除某个会话,同步和清除都跳过,可以写多次 |
159
161
  | `--exclude-file <文件>` | 从文件读取排除列表,每行一个会话 ID |
@@ -174,7 +176,22 @@ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
174
176
  claude-session-sync --exclude-file ~/no-sync.txt --apply
175
177
  ```
176
178
 
177
- 会话 ID 就是 `~/.claude/projects/<项目>/` 下 `.jsonl` 文件的文件名;账号 ID 是 `claude-code-sessions/` 下第一层目录名,预览输出里会显示前 8 位。
179
+ 会话 ID 就是 `~/.claude/projects/<项目>/` 下 `.jsonl` 文件的文件名;账号 ID 是 `claude-code-sessions/` 下第一层目录名,预览输出里会显示前 8 位,`--account` 和 `--set-alias` 直接用这 8 位就行。
180
+
181
+ ### 账号名称
182
+
183
+ 桌面端只按 ID 存放账号,所以输出里会尽量把能找到的名称显示出来:
184
+
185
+ ```
186
+ [工作号 · Acme Inc · fan***@example.com · 当前 CLI 登录] (b8073c71…/677f8bdf…) 已有 122,待补 1
187
+ [07ac42e2…/00000000…] 已有 119,待补 1
188
+ ```
189
+
190
+ - **CLI 当前登录的账号**:从 `~/.claude.json` 自动识别(只读取账号和组织 ID、邮箱、组织名)。
191
+ - **CLI 以前登录过的账号**:每次 `--apply` 都会把当前 CLI 账号记到 `~/.claude-desktop-sync/accounts.json`,切换账号后仍然能显示名称。
192
+ - **任意账号**:自己起别名,比如 `claude-session-sync --set-alias b8073c71=工作号`;写成 `<账号>/<组织>` 前缀可以只给某个组织起名。
193
+
194
+ 邮箱始终打码显示(如 `fan***@example.com`),因为这份输出经常会被贴进 issue。预览模式不会写入 `accounts.json`。
178
195
 
179
196
  ---
180
197
 
@@ -204,7 +221,7 @@ claude-session-sync --exclude-file ~/no-sync.txt --apply
204
221
  不记得用哪种方式装的,运行 `uv tool list` 或 `pipx list` 看一下。
205
222
 
206
223
  - **升级**会直接替换旧版本,命令名不变。再运行一次 `uv tool install` 不会升级,要用 `upgrade`。`~/.claude-desktop-sync/` 里的日志和备份会保留,升级后 `--undo` 依然可以撤销以前的操作。升级完可以用 `claude-session-sync --version` 确认版本。
207
- - **卸载只删除程序本身。** 已经同步到桌面端侧边栏的会话和 `~/.claude-desktop-sync/` 都会保留。想一并撤销,请在**卸载前**退出桌面端,反复运行 `claude-session-sync --undo`,直到提示没有可撤销的记录;不再需要备份的话,再手动删除 `~/.claude-desktop-sync/`。
224
+ - **卸载只删除程序本身。** 已经同步到桌面端侧边栏的会话和 `~/.claude-desktop-sync/` 都会保留。想一并撤销,请在**卸载前**退出桌面端,反复运行 `claude-session-sync --undo`,直到提示没有可撤销的记录;不再需要备份的话,再手动删除 `~/.claude-desktop-sync/`(里面也存着账号名称和别名)。
208
225
 
209
226
  ---
210
227
 
@@ -33,6 +33,7 @@ import glob
33
33
  import json
34
34
  import os
35
35
  import platform
36
+ import re
36
37
  import shutil
37
38
  import subprocess
38
39
  import sys
@@ -40,7 +41,7 @@ import time
40
41
  import uuid
41
42
  from pathlib import Path, PurePosixPath, PureWindowsPath
42
43
 
43
- __version__ = "0.1.2"
44
+ __version__ = "0.2.0"
44
45
 
45
46
  HOME = Path.home()
46
47
  JOURNAL_DIR = HOME / ".claude-desktop-sync"
@@ -578,20 +579,189 @@ def purge_archived(indexes, archived_ids, projects, apply, trash, excluded=(), p
578
579
  return created, moved
579
580
 
580
581
 
582
+ # ---------------------------------------------------------------- 账号名称
583
+ #
584
+ # 桌面端只按 ID 存放账号目录,本机没有公开的地方记录其他账号的邮箱或组织名。
585
+ # 名称来源(按优先级):
586
+ # 1. 用户用 --set-alias 起的别名
587
+ # 2. CLI 当前登录的账号(~/.claude.json 的 oauthAccount)
588
+ # 3. 以前 --apply 时记下的 CLI 账号信息(~/.claude-desktop-sync/accounts.json)
589
+ # 邮箱一律打码后再显示,因为预览输出经常会被贴进 issue。
590
+
591
+ EMAIL_RE = re.compile(r"([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})")
592
+
593
+
594
+ def mask_email(email):
595
+ local, _, domain = email.partition("@")
596
+ keep = min(3, max(1, len(local) // 2))
597
+ return f"{local[:keep]}***@{domain}"
598
+
599
+
600
+ def mask_emails_in(text):
601
+ return EMAIL_RE.sub(lambda m: mask_email(m.group(0)), text)
602
+
603
+
604
+ def accounts_file():
605
+ return JOURNAL_DIR / "accounts.json"
606
+
607
+
608
+ def load_account_book():
609
+ try:
610
+ data = json.loads(accounts_file().read_text(encoding="utf-8"))
611
+ except (OSError, ValueError):
612
+ data = {}
613
+ if not isinstance(data, dict):
614
+ data = {}
615
+ return {
616
+ "accounts": data.get("accounts") if isinstance(data.get("accounts"), dict) else {},
617
+ "aliases": data.get("aliases") if isinstance(data.get("aliases"), dict) else {},
618
+ }
619
+
620
+
621
+ def save_account_book(book):
622
+ JOURNAL_DIR.mkdir(parents=True, exist_ok=True)
623
+ p = accounts_file()
624
+ p.write_text(json.dumps({"version": 1, **book}, ensure_ascii=False, indent=2), encoding="utf-8")
625
+ try:
626
+ os.chmod(p, 0o600) # 里面有邮箱,只让自己读
627
+ except OSError:
628
+ pass
629
+
630
+
631
+ def cli_login_account():
632
+ """读取 CLI 当前登录账号,只取识别账号需要的字段。读不到返回 None。"""
633
+ cfg = os.environ.get("CLAUDE_CONFIG_DIR")
634
+ candidates = ([Path(cfg).expanduser() / ".claude.json"] if cfg else []) + [HOME / ".claude.json"]
635
+ for f in candidates:
636
+ try:
637
+ a = json.loads(f.read_text(encoding="utf-8")).get("oauthAccount")
638
+ except (OSError, ValueError, AttributeError):
639
+ continue
640
+ if isinstance(a, dict) and isinstance(a.get("accountUuid"), str):
641
+ return {
642
+ "account": a["accountUuid"],
643
+ "org": a.get("organizationUuid") or "",
644
+ "email": a.get("emailAddress") or "",
645
+ "org_name": a.get("organizationName") or "",
646
+ }
647
+ return None
648
+
649
+
650
+ def remember_cli_account(book, cli_acct):
651
+ """把 CLI 当前账号记进账号簿;有变化返回 True。"""
652
+ if not cli_acct:
653
+ return False
654
+ key = f"{cli_acct['account']}/{cli_acct['org']}"
655
+ info = {"email": cli_acct["email"], "org_name": cli_acct["org_name"]}
656
+ old = book["accounts"].get(key) or {}
657
+ if {k: old.get(k) for k in info} == info:
658
+ return False
659
+ book["accounts"][key] = {**info, "seen_at": dt.datetime.now().isoformat(timespec="seconds")}
660
+ return True
661
+
662
+
663
+ def account_label(acct, org, book, cli_acct):
664
+ """返回账号的可读名称(可能为空)。"""
665
+ parts = []
666
+ alias = book["aliases"].get(f"{acct}/{org}") or book["aliases"].get(acct)
667
+ if alias:
668
+ parts.append(alias)
669
+ is_cli = bool(cli_acct and cli_acct["account"] == acct and cli_acct["org"] == org)
670
+ info = (
671
+ {"email": cli_acct["email"], "org_name": cli_acct["org_name"]}
672
+ if is_cli
673
+ else book["accounts"].get(f"{acct}/{org}") or {}
674
+ )
675
+ email = mask_email(info["email"]) if EMAIL_RE.fullmatch(info.get("email") or "") else ""
676
+ org_name = mask_emails_in(info.get("org_name") or "")
677
+ if org_name and not (email and email in org_name): # 个人组织名常常就是"邮箱's Organization"
678
+ parts.append(org_name)
679
+ if email:
680
+ parts.append(email)
681
+ if is_cli:
682
+ parts.append(t("current CLI login", "当前 CLI 登录"))
683
+ return " · ".join(parts)
684
+
685
+
686
+ def account_heading(acct, org, book, cli_acct):
687
+ ids = f"{acct[:8]}…/{org[:8]}…"
688
+ label = account_label(acct, org, book, cli_acct)
689
+ return f"[{label}] ({ids})" if label else f"[{ids}]"
690
+
691
+
692
+ def resolve_account_key(key, all_accts):
693
+ """把 <账号ID前缀>[/<组织ID前缀>] 解析成完整 ID。返回 (账号ID, 组织ID或None);出错时退出。"""
694
+ acct_part, _, org_part = key.partition("/")
695
+ if not acct_part:
696
+ sys.exit(t(f"Invalid account ID: {key!r}", f"账号 ID 无效:{key!r}"))
697
+ pairs = {(a, o) for _, a, o, _ in all_accts if a.startswith(acct_part) and o.startswith(org_part)}
698
+ acct_ids = {a for a, _ in pairs}
699
+ if not pairs:
700
+ sys.exit(t(f"No account matches {key!r}.", f"没有匹配 {key!r} 的账号。"))
701
+ if len(acct_ids) > 1:
702
+ sys.exit(
703
+ t(
704
+ f"{key!r} matches several accounts: {', '.join(sorted(x[:8] for x in acct_ids))}. Use a longer prefix.",
705
+ f"{key!r} 匹配到多个账号:{', '.join(sorted(x[:8] for x in acct_ids))}。请写更长的前缀。",
706
+ )
707
+ )
708
+ acct = acct_ids.pop()
709
+ if not org_part:
710
+ return acct, None
711
+ if len(pairs) > 1:
712
+ sys.exit(t(f"{key!r} matches several organizations.", f"{key!r} 匹配到多个组织。"))
713
+ return acct, next(iter(pairs))[1]
714
+
715
+
716
+ def set_aliases(specs, all_accts):
717
+ book = load_account_book()
718
+ for spec in specs:
719
+ key, sep, name = spec.partition("=")
720
+ if not sep:
721
+ sys.exit(
722
+ t(f"--set-alias expects ID=NAME, got {spec!r}", f"--set-alias 的格式是 ID=名称,收到的是 {spec!r}")
723
+ )
724
+ acct, org = resolve_account_key(key.strip(), all_accts)
725
+ full = f"{acct}/{org}" if org else acct
726
+ name = name.strip()
727
+ if name:
728
+ book["aliases"][full] = name
729
+ print(t(f"Alias set: {full} = {name}", f"已设置别名:{full} = {name}"))
730
+ else:
731
+ book["aliases"].pop(full, None)
732
+ print(t(f"Alias removed: {full}", f"已删除别名:{full}"))
733
+ save_account_book(book)
734
+
735
+
581
736
  # ---------------------------------------------------------------- 主流程
582
737
 
583
738
 
739
+ def apply_lang_flag(argv):
740
+ """在构建帮助文本之前先读取 --lang,让 --help 和后续输出都使用指定语言。
741
+ 非法取值交给正式的解析器报错。"""
742
+ global _LANG_ZH
743
+ pre = argparse.ArgumentParser(add_help=False)
744
+ pre.add_argument("--lang")
745
+ known, _ = pre.parse_known_args(argv)
746
+ if known.lang in ("en", "zh"):
747
+ _LANG_ZH = known.lang == "zh"
748
+
749
+
584
750
  def main(argv=None):
585
751
  use_safe_stdio()
752
+ apply_lang_flag(argv)
586
753
  ap = argparse.ArgumentParser(
587
754
  prog="claude-session-sync",
588
755
  formatter_class=argparse.RawDescriptionHelpFormatter,
589
756
  description=t(
590
757
  "Sync local Claude Code CLI sessions into the Claude desktop app's Code sidebar (multi-account).\n"
591
758
  "Archived sessions and sessions deleted in the desktop app are skipped. "
592
- "Previews by default; pass --apply to write.",
759
+ "Previews by default; pass --apply to write.\n"
760
+ # 这一行固定用中文:给终端设成英文的中文用户看
761
+ "中文输出:加 --lang zh,或设置环境变量 CLAUDE_SESSION_SYNC_LANG=zh",
593
762
  "把本地 Claude Code CLI 会话同步到 Claude 桌面端 Code 标签页的侧边栏(支持多账号)。\n"
594
- "自动排除已归档、桌面端已删除的会话。默认只预览,加 --apply 才写入。",
763
+ "自动排除已归档、桌面端已删除的会话。默认只预览,加 --apply 才写入。\n"
764
+ "English output: pass --lang en, or set CLAUDE_SESSION_SYNC_LANG=en",
595
765
  ),
596
766
  epilog=t(
597
767
  """\
@@ -612,7 +782,7 @@ Notes:
612
782
  * Quit the desktop app completely before writing, and reopen it afterwards.
613
783
  * Journals and backups live in ~/.claude-desktop-sync/; --undo steps back one run at a time.
614
784
  * The desktop index format is undocumented and may break with desktop updates.
615
- * Output language follows LANG; set CLAUDE_SESSION_SYNC_LANG=en or zh to override.
785
+ * Output language: --lang, then CLAUDE_SESSION_SYNC_LANG, then LC_ALL / LC_MESSAGES / LANG.
616
786
  """,
617
787
  """\
618
788
  常用流程:
@@ -632,11 +802,19 @@ Notes:
632
802
  * 写入前请彻底退出桌面端(菜单栏 ⌘Q),写完重新打开。
633
803
  * 日志与备份在 ~/.claude-desktop-sync/,--undo 按时间倒序逐次撤销。
634
804
  * 桌面端索引格式无官方文档,桌面端更新后可能失效。
635
- * 输出语言跟随 LANG,可用 CLAUDE_SESSION_SYNC_LANG=en 或 zh 强制指定。
805
+ * 输出语言:依次看 --lang、CLAUDE_SESSION_SYNC_LANG、LC_ALL / LC_MESSAGES / LANG。
636
806
  """,
637
807
  ),
638
808
  )
639
809
  ap.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
810
+ ap.add_argument(
811
+ "--lang",
812
+ choices=["en", "zh"],
813
+ help=t(
814
+ "output language (default: from CLAUDE_SESSION_SYNC_LANG or the locale)",
815
+ "输出语言(默认:按 CLAUDE_SESSION_SYNC_LANG 或系统语言)",
816
+ ),
817
+ )
640
818
  ap.add_argument(
641
819
  "--apply", action="store_true", help=t("actually write (default: preview only)", "实际写入(默认只预览)")
642
820
  )
@@ -649,7 +827,21 @@ Notes:
649
827
  ap.add_argument(
650
828
  "--account",
651
829
  action="append",
652
- help=t("only sync this account ID (repeatable)", "只同步指定账号ID(可多次使用)"),
830
+ help=t(
831
+ "only sync this account; an ID prefix such as the 8 characters shown in the output works, "
832
+ "add /<org-id-prefix> to pick one organization (repeatable)",
833
+ "只同步指定账号;可以只写输出里显示的前 8 位,加 /<组织ID前缀> 可只选一个组织(可多次使用)",
834
+ ),
835
+ )
836
+ ap.add_argument(
837
+ "--set-alias",
838
+ action="append",
839
+ metavar="ID=NAME",
840
+ help=t(
841
+ "give an account a display name, e.g. b8073c71=work (ID prefix, optionally /<org-id-prefix>); "
842
+ "an empty name removes it; repeatable, then exits",
843
+ "给账号起显示名称,如 b8073c71=工作号(ID 前缀,可加 /<组织ID前缀>);名称留空则删除;可多次使用,设置完即退出",
844
+ ),
653
845
  )
654
846
  ap.add_argument(
655
847
  "--project",
@@ -731,10 +923,17 @@ Notes:
731
923
  )
732
924
 
733
925
  accts = account_dirs(roots)
734
- if args.account:
735
- accts = [a for a in accts if a[1] in args.account]
736
926
  if not accts:
737
927
  sys.exit(t("No account directories found.", "没有找到任何账号目录。"))
928
+ if args.set_alias:
929
+ set_aliases(args.set_alias, accts)
930
+ return
931
+ if args.account:
932
+ wanted = [resolve_account_key(v, accts) for v in args.account]
933
+ accts = [a for a in accts if any(a[1] == ac and (og is None or a[2] == og) for ac, og in wanted)]
934
+
935
+ book = load_account_book()
936
+ cli_acct = cli_login_account()
738
937
 
739
938
  excluded = set(args.exclude)
740
939
  if args.exclude_file:
@@ -781,7 +980,14 @@ Notes:
781
980
  if args.show_template:
782
981
  for _d, (acct, org, entries, _, _) in indexes.items():
783
982
  tpl = pick_template(entries)
784
- print(t(f"\n[{acct}/{org}] {len(entries)} index file(s)", f"\n[{acct}/{org}] {len(entries)} 个索引文件"))
983
+ label = account_label(acct, org, book, cli_acct)
984
+ label = f" {label}" if label else ""
985
+ print(
986
+ t(
987
+ f"\n[{acct}/{org}]{label} {len(entries)} index file(s)",
988
+ f"\n[{acct}/{org}]{label} {len(entries)} 个索引文件",
989
+ )
990
+ )
785
991
  if tpl:
786
992
  print(
787
993
  json.dumps(
@@ -822,6 +1028,8 @@ Notes:
822
1028
 
823
1029
  if args.apply:
824
1030
  ensure_desktop_stopped(args.force)
1031
+ if remember_cli_account(book, cli_acct):
1032
+ save_account_book(book)
825
1033
 
826
1034
  created = []
827
1035
  for d, (acct, org, entries, known, archived) in indexes.items():
@@ -834,8 +1042,8 @@ Notes:
834
1042
  ]
835
1043
  print(
836
1044
  t(
837
- f"[{acct[:8]}…/{org[:8]}…] existing {len(known)}, to add {len(todo)}",
838
- f"[{acct[:8]}…/{org[:8]}…] 已有 {len(known)},待补 {len(todo)}",
1045
+ f"{account_heading(acct, org, book, cli_acct)} existing {len(known)}, to add {len(todo)}",
1046
+ f"{account_heading(acct, org, book, cli_acct)} 已有 {len(known)},待补 {len(todo)}",
839
1047
  )
840
1048
  )
841
1049
  if not template:
@@ -11,6 +11,8 @@ def env(tmp_path, monkeypatch):
11
11
  monkeypatch.setattr(cli, "JOURNAL_DIR", e.journal)
12
12
  # 默认假装桌面端没在运行,避免开发机上开着桌面端时测试失败
13
13
  monkeypatch.setattr(cli, "desktop_running", lambda: False)
14
+ # 不读取开发机上真实的 ~/.claude.json,需要的测试自己设置
15
+ monkeypatch.setattr(cli, "cli_login_account", lambda: None)
14
16
  return e
15
17
 
16
18
 
@@ -411,3 +411,136 @@ def test_include_recent_does_not_affect_purge(env):
411
411
  jsonl = env.add_cli_session("busy", mtime=time.time())
412
412
  env.run("--purge-archived", "--apply", "--include-recent")
413
413
  assert arch.exists() and jsonl.exists()
414
+
415
+
416
+ # ---------------------------------------------------------------- --lang
417
+
418
+
419
+ def test_lang_flag_switches_help_to_chinese(monkeypatch, capsys):
420
+ monkeypatch.setenv("CLAUDE_SESSION_SYNC_LANG", "en") # --lang 优先于环境变量
421
+ with pytest.raises(SystemExit):
422
+ cli.main(["--lang", "zh", "--help"])
423
+ out = capsys.readouterr().out
424
+ assert "实际写入" in out and "English output: pass --lang en" in out
425
+
426
+
427
+ def test_english_help_shows_chinese_hint(capsys):
428
+ with pytest.raises(SystemExit):
429
+ cli.main(["--help"])
430
+ out = capsys.readouterr().out
431
+ assert "actually write" in out
432
+ assert "中文输出:加 --lang zh" in out
433
+
434
+
435
+ def test_lang_flag_switches_run_output(env, capsys):
436
+ env.add_index("existing")
437
+ env.add_cli_session("new-1")
438
+ env.run("--lang=zh")
439
+ assert "预览模式" in capsys.readouterr().out
440
+
441
+
442
+ def test_invalid_lang_is_rejected(capsys):
443
+ with pytest.raises(SystemExit) as e:
444
+ cli.main(["--lang", "fr"])
445
+ assert e.value.code == 2
446
+ assert "invalid choice" in capsys.readouterr().err
447
+
448
+
449
+ # ---------------------------------------------------------------- 账号名称、别名、前缀
450
+
451
+
452
+ CLI_ACCT = {"account": ACCT, "org": ORG, "email": "alice@example.com", "org_name": "alice@example.com's Organization"}
453
+
454
+
455
+ @pytest.mark.parametrize(
456
+ ("email", "masked"),
457
+ [("alice@example.com", "al***@example.com"), ("ab@x.io", "a***@x.io"), ("a@x.io", "a***@x.io")],
458
+ )
459
+ def test_mask_email(email, masked):
460
+ assert cli.mask_email(email) == masked
461
+
462
+
463
+ def test_cli_login_account_reads_only_needed_fields(tmp_path, monkeypatch):
464
+ monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
465
+ (tmp_path / ".claude.json").write_text(
466
+ json.dumps(
467
+ {
468
+ "oauthAccount": {
469
+ "accountUuid": ACCT,
470
+ "organizationUuid": ORG,
471
+ "emailAddress": "alice@example.com",
472
+ "organizationName": "Acme",
473
+ "billingType": "secret-ish",
474
+ }
475
+ }
476
+ )
477
+ )
478
+ assert cli.cli_login_account() == {"account": ACCT, "org": ORG, "email": "alice@example.com", "org_name": "Acme"}
479
+
480
+
481
+ def test_heading_shows_masked_cli_account(env, monkeypatch, capsys):
482
+ monkeypatch.setattr(cli, "cli_login_account", lambda: dict(CLI_ACCT))
483
+ env.add_index("existing")
484
+ env.org_dir(ACCT, ORG_EMPTY)
485
+ env.run()
486
+ out = capsys.readouterr().out
487
+ assert "[al***@example.com · current CLI login] (aaaaaaaa…/bbbbbbbb…)" in out
488
+ assert "alice@example.com" not in out # 完整邮箱不能出现,组织名里的也不行
489
+ assert "[aaaaaaaa…/bbbbbbbb…] existing 0" in out # 另一个组织没有名称
490
+ assert not env.journal.exists() # 预览不写账号记录
491
+
492
+
493
+ def test_apply_remembers_cli_account(env, monkeypatch, capsys):
494
+ monkeypatch.setattr(cli, "cli_login_account", lambda: dict(CLI_ACCT, org_name="Acme"))
495
+ env.add_index("existing")
496
+ env.run("--apply")
497
+ saved = json.loads((env.journal / "accounts.json").read_text())
498
+ assert saved["accounts"][f"{ACCT}/{ORG}"]["email"] == "alice@example.com"
499
+ # 之后切换到别的 CLI 账号,仍然能显示记下来的名称
500
+ monkeypatch.setattr(cli, "cli_login_account", lambda: None)
501
+ capsys.readouterr()
502
+ env.run()
503
+ assert "[Acme · al***@example.com] (aaaaaaaa…/bbbbbbbb…)" in capsys.readouterr().out
504
+
505
+
506
+ def test_set_alias_and_remove(env, capsys):
507
+ env.add_index("existing")
508
+ env.org_dir(ACCT, ORG_EMPTY)
509
+ env.run("--set-alias", "aaaaaaaa=work") # 只写账号前缀:对该账号的所有组织生效
510
+ env.run("--set-alias", "aaaaaaaa/bbbbbbbb-0000-4000-8000-000000000002=side") # 组织级别优先
511
+ capsys.readouterr()
512
+ env.run()
513
+ out = capsys.readouterr().out
514
+ assert "[work] (aaaaaaaa…/bbbbbbbb…) existing 1" in out
515
+ assert "[side] (aaaaaaaa…/bbbbbbbb…) existing 0" in out
516
+ env.run("--set-alias", "aaaaaaaa=")
517
+ saved = json.loads((env.journal / "accounts.json").read_text())
518
+ assert ACCT not in saved["aliases"] and f"{ACCT}/{ORG_EMPTY}" in saved["aliases"]
519
+
520
+
521
+ def test_set_alias_errors(env):
522
+ env.add_index("existing")
523
+ with pytest.raises(SystemExit) as e:
524
+ env.run("--set-alias", "zzzz=x")
525
+ assert "No account matches" in str(e.value.code)
526
+ with pytest.raises(SystemExit) as e:
527
+ env.run("--set-alias", "aaaaaaaa")
528
+ assert "ID=NAME" in str(e.value.code)
529
+
530
+
531
+ def test_account_prefix_filter(env):
532
+ other = "cccccccc-0000-4000-8000-000000000001"
533
+ env.add_index("existing")
534
+ env.add_index("existing", acct=other)
535
+ env.add_cli_session("new-1")
536
+ env.run("--apply", "--account", "cccccccc")
537
+ assert cli_ids(env.index_entries(other, ORG)) == ["existing", "new-1"]
538
+ assert cli_ids(env.index_entries(ACCT, ORG)) == ["existing"]
539
+
540
+
541
+ def test_ambiguous_account_prefix_is_rejected(env):
542
+ env.add_index("existing")
543
+ env.add_index("existing", acct="aaaaaaaa-1111-4000-8000-000000000001")
544
+ with pytest.raises(SystemExit) as e:
545
+ env.run("--account", "aaaaaaaa")
546
+ assert "matches several accounts" in str(e.value.code)