claude-session-sync-cli 0.1.3__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,12 @@ 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
+
7
13
  ## [0.1.3] - 2026-11-11
8
14
 
9
15
  ### Added
@@ -32,7 +38,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
32
38
  - `--version` and `--help` with examples.
33
39
  - English and Chinese output, chosen from the locale; override with `CLAUDE_SESSION_SYNC_LANG=en|zh`.
34
40
 
35
- [Unreleased]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.3...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
36
43
  [0.1.3]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.2...v0.1.3
37
44
  [0.1.2]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.1...v0.1.2
38
45
  [0.1.1]: https://github.com/xushenjie/claude-session-sync/compare/v0.1.0...v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: claude-session-sync-cli
3
- Version: 0.1.3
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
@@ -183,7 +183,8 @@ claude-session-sync --purge-archived --apply # do it
183
183
  | `--apply` | Actually write |
184
184
  | `--undo` | Undo the most recent write or deletion |
185
185
  | `--show-template` | Print each account's template fields and exit (for troubleshooting) |
186
- | `--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 |
187
188
  | `--project <string>` | Only handle sessions whose working directory contains this string (sync and purge) |
188
189
  | `--exclude <session-id>` | Exclude a session from sync and purge; repeatable |
189
190
  | `--exclude-file <file>` | Read excluded session IDs from a file, one per line |
@@ -204,7 +205,22 @@ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
204
205
  claude-session-sync --exclude-file ~/no-sync.txt --apply
205
206
  ```
206
207
 
207
- 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`.
208
224
 
209
225
  ---
210
226
 
@@ -234,7 +250,7 @@ Use the package name `claude-session-sync-cli` (not the command name) with your
234
250
  Not sure which one you used? `uv tool list` or `pipx list` will show it.
235
251
 
236
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.
237
- - **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.
238
254
 
239
255
  ---
240
256
 
@@ -154,7 +154,8 @@ claude-session-sync --purge-archived --apply # do it
154
154
  | `--apply` | Actually write |
155
155
  | `--undo` | Undo the most recent write or deletion |
156
156
  | `--show-template` | Print each account's template fields and exit (for troubleshooting) |
157
- | `--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 |
158
159
  | `--project <string>` | Only handle sessions whose working directory contains this string (sync and purge) |
159
160
  | `--exclude <session-id>` | Exclude a session from sync and purge; repeatable |
160
161
  | `--exclude-file <file>` | Read excluded session IDs from a file, one per line |
@@ -175,7 +176,22 @@ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
175
176
  claude-session-sync --exclude-file ~/no-sync.txt --apply
176
177
  ```
177
178
 
178
- 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`.
179
195
 
180
196
  ---
181
197
 
@@ -205,7 +221,7 @@ Use the package name `claude-session-sync-cli` (not the command name) with your
205
221
  Not sure which one you used? `uv tool list` or `pipx list` will show it.
206
222
 
207
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.
208
- - **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.
209
225
 
210
226
  ---
211
227
 
@@ -154,7 +154,8 @@ claude-session-sync --purge-archived --apply # 执行
154
154
  | `--apply` | 实际写入 |
155
155
  | `--undo` | 撤销最近一次写入或删除 |
156
156
  | `--show-template` | 显示每个账号的模板字段后退出(排查用) |
157
- | `--account <账号ID>` | 只同步指定账号,可以写多次 |
157
+ | `--account <账号ID>` | 只同步指定账号。写输出里显示的前 8 位就够了;加 `/<组织ID前缀>` 可以只选一个组织。可以写多次 |
158
+ | `--set-alias <ID>=<名称>` | 给账号起显示名称(见[账号名称](#账号名称)),名称留空则删除。可以写多次,保存后退出 |
158
159
  | `--project <字符串>` | 只处理工作目录里包含这个字符串的会话(同步和清除都生效) |
159
160
  | `--exclude <会话ID>` | 排除某个会话,同步和清除都跳过,可以写多次 |
160
161
  | `--exclude-file <文件>` | 从文件读取排除列表,每行一个会话 ID |
@@ -175,7 +176,22 @@ claude-session-sync --account 1a2b3c4d-... --project myapp --apply
175
176
  claude-session-sync --exclude-file ~/no-sync.txt --apply
176
177
  ```
177
178
 
178
- 会话 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`。
179
195
 
180
196
  ---
181
197
 
@@ -205,7 +221,7 @@ claude-session-sync --exclude-file ~/no-sync.txt --apply
205
221
  不记得用哪种方式装的,运行 `uv tool list` 或 `pipx list` 看一下。
206
222
 
207
223
  - **升级**会直接替换旧版本,命令名不变。再运行一次 `uv tool install` 不会升级,要用 `upgrade`。`~/.claude-desktop-sync/` 里的日志和备份会保留,升级后 `--undo` 依然可以撤销以前的操作。升级完可以用 `claude-session-sync --version` 确认版本。
208
- - **卸载只删除程序本身。** 已经同步到桌面端侧边栏的会话和 `~/.claude-desktop-sync/` 都会保留。想一并撤销,请在**卸载前**退出桌面端,反复运行 `claude-session-sync --undo`,直到提示没有可撤销的记录;不再需要备份的话,再手动删除 `~/.claude-desktop-sync/`。
224
+ - **卸载只删除程序本身。** 已经同步到桌面端侧边栏的会话和 `~/.claude-desktop-sync/` 都会保留。想一并撤销,请在**卸载前**退出桌面端,反复运行 `claude-session-sync --undo`,直到提示没有可撤销的记录;不再需要备份的话,再手动删除 `~/.claude-desktop-sync/`(里面也存着账号名称和别名)。
209
225
 
210
226
  ---
211
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.3"
44
+ __version__ = "0.2.0"
44
45
 
45
46
  HOME = Path.home()
46
47
  JOURNAL_DIR = HOME / ".claude-desktop-sync"
@@ -578,6 +579,160 @@ 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
 
@@ -672,7 +827,21 @@ Notes:
672
827
  ap.add_argument(
673
828
  "--account",
674
829
  action="append",
675
- 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
+ ),
676
845
  )
677
846
  ap.add_argument(
678
847
  "--project",
@@ -754,10 +923,17 @@ Notes:
754
923
  )
755
924
 
756
925
  accts = account_dirs(roots)
757
- if args.account:
758
- accts = [a for a in accts if a[1] in args.account]
759
926
  if not accts:
760
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()
761
937
 
762
938
  excluded = set(args.exclude)
763
939
  if args.exclude_file:
@@ -804,7 +980,14 @@ Notes:
804
980
  if args.show_template:
805
981
  for _d, (acct, org, entries, _, _) in indexes.items():
806
982
  tpl = pick_template(entries)
807
- 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
+ )
808
991
  if tpl:
809
992
  print(
810
993
  json.dumps(
@@ -845,6 +1028,8 @@ Notes:
845
1028
 
846
1029
  if args.apply:
847
1030
  ensure_desktop_stopped(args.force)
1031
+ if remember_cli_account(book, cli_acct):
1032
+ save_account_book(book)
848
1033
 
849
1034
  created = []
850
1035
  for d, (acct, org, entries, known, archived) in indexes.items():
@@ -857,8 +1042,8 @@ Notes:
857
1042
  ]
858
1043
  print(
859
1044
  t(
860
- f"[{acct[:8]}…/{org[:8]}…] existing {len(known)}, to add {len(todo)}",
861
- 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)}",
862
1047
  )
863
1048
  )
864
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
 
@@ -444,3 +444,103 @@ def test_invalid_lang_is_rejected(capsys):
444
444
  cli.main(["--lang", "fr"])
445
445
  assert e.value.code == 2
446
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)