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.
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/CHANGELOG.md +8 -1
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/PKG-INFO +20 -4
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/README.md +19 -3
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/README.zh-CN.md +19 -3
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/src/claude_session_sync/cli.py +192 -7
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/tests/conftest.py +2 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/tests/test_sync.py +100 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/.gitignore +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/LICENSE +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/SECURITY.md +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/pyproject.toml +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/src/claude_session_sync/__init__.py +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/src/claude_session_sync/__main__.py +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/tests/__init__.py +0 -0
- {claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/tests/fakes.py +0 -0
|
@@ -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.
|
|
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.
|
|
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;
|
|
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;
|
|
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
|
|
{claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/src/claude_session_sync/cli.py
RENAMED
|
@@ -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.
|
|
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(
|
|
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
|
-
|
|
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"
|
|
861
|
-
f"
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/src/claude_session_sync/__init__.py
RENAMED
|
File without changes
|
{claude_session_sync_cli-0.1.3 → claude_session_sync_cli-0.2.0}/src/claude_session_sync/__main__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|