claude-multiacc 2.0.52 → 2.0.54
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.
- package/README.md +10 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/claude_pool_prepare.py +206 -0
- package/lib/common.sh +8 -2
- package/package.json +2 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/test_account_seeding.py +13 -0
- package/tests/test_claude_pool_prepare.py +107 -0
package/README.md
CHANGED
|
@@ -326,6 +326,16 @@ maintains only while a run is alive. The shim spawns a short detached poll befor
|
|
|
326
326
|
session's own start time — in `<acct>/.sessions-index`; selection then tail-scans those
|
|
327
327
|
transcripts.
|
|
328
328
|
|
|
329
|
+
Fresh account seeding creates `~/.claude/projects` before linking each account, even
|
|
330
|
+
if stock Claude has never run on that machine. For an existing pool with private
|
|
331
|
+
`<acct>/projects` trees, run `python3 lib/claude_pool_prepare.py apply`. It
|
|
332
|
+
defers a private-history migration while a pooled Claude session is active,
|
|
333
|
+
merges transcripts into the shared tree when idle, archives
|
|
334
|
+
conflicting versions under `~/.claude-accounts/history-repair/`, and sets the
|
|
335
|
+
interactive default to `bypassPermissions` in the global and pooled account
|
|
336
|
+
settings. `check` reports readiness without changing files. The bypass default is
|
|
337
|
+
an operator choice for trusted runner machines; ordinary installs do not change it.
|
|
338
|
+
|
|
329
339
|
Everything about the rule is built to under-claim rather than over-claim, because a
|
|
330
340
|
wrongly parked account is worse than a missed limit:
|
|
331
341
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepare one local Claude pool's shared history and interactive permission default."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def account_dirs(root: Path) -> list[Path]:
|
|
16
|
+
manifest = root / "accounts.json"
|
|
17
|
+
if not manifest.is_file():
|
|
18
|
+
return []
|
|
19
|
+
data = json.loads(manifest.read_text())
|
|
20
|
+
ids = [item["id"] for item in data.get("accounts", [])]
|
|
21
|
+
if any(not re.fullmatch(r"acct-[0-9]{2}", value) for value in ids):
|
|
22
|
+
raise ValueError("invalid account id in manifest")
|
|
23
|
+
return [root / value for value in ids if (root / value).is_dir()]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def same_history(path: Path, shared: Path) -> bool:
|
|
27
|
+
return path.is_dir() and path.resolve() == shared.resolve()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def active_pool_session(accounts: list[Path]) -> bool:
|
|
31
|
+
for account in accounts:
|
|
32
|
+
for marker in (account / "sessions").glob("*.json"):
|
|
33
|
+
if marker.stem.isdecimal():
|
|
34
|
+
try:
|
|
35
|
+
os.kill(int(marker.stem), 0)
|
|
36
|
+
except ProcessLookupError:
|
|
37
|
+
continue
|
|
38
|
+
except PermissionError:
|
|
39
|
+
return True
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_conflict(path: Path, archive: Path, account: str, relative: Path) -> None:
|
|
45
|
+
destination = archive / "conflicts" / account / relative
|
|
46
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
number = 1
|
|
48
|
+
while os.path.lexists(destination):
|
|
49
|
+
destination = destination.with_name(f"{relative.name}.{number}")
|
|
50
|
+
number += 1
|
|
51
|
+
path.rename(destination)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def merge_history(source: Path, shared: Path, archive: Path) -> tuple[int, int]:
|
|
55
|
+
moved = conflicts = 0
|
|
56
|
+
for base, directories, files in os.walk(source):
|
|
57
|
+
current = Path(base)
|
|
58
|
+
relative = current.relative_to(source)
|
|
59
|
+
destination = shared / relative
|
|
60
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
for name in list(directories):
|
|
62
|
+
if (current / name).is_symlink():
|
|
63
|
+
directories.remove(name)
|
|
64
|
+
files.append(name)
|
|
65
|
+
for name in files:
|
|
66
|
+
item = current / name
|
|
67
|
+
target = destination / name
|
|
68
|
+
if not os.path.lexists(target):
|
|
69
|
+
item.rename(target)
|
|
70
|
+
moved += 1
|
|
71
|
+
elif item.lstat().st_mtime_ns > target.lstat().st_mtime_ns:
|
|
72
|
+
save_conflict(target, archive, source.name, relative / name)
|
|
73
|
+
item.rename(target)
|
|
74
|
+
conflicts += 1
|
|
75
|
+
else:
|
|
76
|
+
save_conflict(item, archive, source.name, relative / name)
|
|
77
|
+
conflicts += 1
|
|
78
|
+
for base, _, _ in os.walk(source, topdown=False):
|
|
79
|
+
Path(base).rmdir()
|
|
80
|
+
return moved, conflicts
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def prepare_history(home: Path, root: Path, accounts: list[Path]) -> tuple[int, int]:
|
|
84
|
+
shared = home / ".claude" / "projects"
|
|
85
|
+
if os.path.lexists(shared) and not shared.is_dir():
|
|
86
|
+
raise ValueError("global Claude history is not a directory")
|
|
87
|
+
for account in accounts:
|
|
88
|
+
projects = account / "projects"
|
|
89
|
+
if (account.is_symlink() and os.path.lexists(projects) and not same_history(projects, shared)
|
|
90
|
+
or projects.is_symlink() and not same_history(projects, shared)
|
|
91
|
+
or os.path.lexists(projects) and not projects.is_dir()):
|
|
92
|
+
raise ValueError(f"custom history entry for {account.name}")
|
|
93
|
+
if active_pool_session(accounts) and any(
|
|
94
|
+
(account / "projects").is_dir() and not same_history(account / "projects", shared)
|
|
95
|
+
for account in accounts):
|
|
96
|
+
raise RuntimeError("active Claude pool session; retry history repair after it exits")
|
|
97
|
+
shared.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
98
|
+
if not shared.is_dir():
|
|
99
|
+
source = next((d / "projects" for d in accounts if not d.is_symlink()
|
|
100
|
+
and (d / "projects").is_dir() and not (d / "projects").is_symlink()), None)
|
|
101
|
+
if source is None:
|
|
102
|
+
shared.mkdir(mode=0o700)
|
|
103
|
+
else:
|
|
104
|
+
source.rename(shared)
|
|
105
|
+
source.symlink_to(shared, target_is_directory=True)
|
|
106
|
+
archive = root / "history-repair"
|
|
107
|
+
originals = archive / "originals"
|
|
108
|
+
for account in accounts:
|
|
109
|
+
projects = account / "projects"
|
|
110
|
+
if same_history(projects, shared):
|
|
111
|
+
continue
|
|
112
|
+
if projects.is_dir():
|
|
113
|
+
originals.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
archive.chmod(0o700)
|
|
115
|
+
originals.chmod(0o700)
|
|
116
|
+
saved = originals / account.name
|
|
117
|
+
number = 1
|
|
118
|
+
while saved.exists():
|
|
119
|
+
saved = originals / f"{account.name}-{number}"
|
|
120
|
+
number += 1
|
|
121
|
+
projects.rename(saved)
|
|
122
|
+
projects.symlink_to(shared, target_is_directory=True)
|
|
123
|
+
moved = conflicts = 0
|
|
124
|
+
if originals.is_dir():
|
|
125
|
+
for source in sorted(originals.iterdir()):
|
|
126
|
+
count, duplicates = merge_history(source, shared, archive)
|
|
127
|
+
moved += count
|
|
128
|
+
conflicts += duplicates
|
|
129
|
+
return moved, conflicts
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def set_default_mode(path: Path) -> bool:
|
|
133
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
134
|
+
permissions = data.setdefault("permissions", {})
|
|
135
|
+
if not isinstance(permissions, dict):
|
|
136
|
+
raise ValueError(f"invalid permissions in {path}")
|
|
137
|
+
if permissions.get("defaultMode") == "bypassPermissions":
|
|
138
|
+
return False
|
|
139
|
+
permissions["defaultMode"] = "bypassPermissions"
|
|
140
|
+
mode = path.stat().st_mode & 0o777 if path.exists() else 0o600
|
|
141
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".settings-", dir=path.parent)
|
|
142
|
+
try:
|
|
143
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
144
|
+
json.dump(data, stream, indent=2, ensure_ascii=False)
|
|
145
|
+
stream.write("\n")
|
|
146
|
+
os.chmod(temporary, mode)
|
|
147
|
+
os.replace(temporary, path)
|
|
148
|
+
finally:
|
|
149
|
+
if os.path.exists(temporary):
|
|
150
|
+
os.unlink(temporary)
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_settings(paths: list[Path]) -> None:
|
|
155
|
+
for path in paths:
|
|
156
|
+
if path.is_symlink():
|
|
157
|
+
raise ValueError(f"custom settings link for {path.parent.name}")
|
|
158
|
+
if path.exists():
|
|
159
|
+
data = json.loads(path.read_text())
|
|
160
|
+
if not isinstance(data, dict) or not isinstance(data.get("permissions", {}), dict):
|
|
161
|
+
raise ValueError(f"invalid settings for {path.parent.name}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def is_ready(home: Path, accounts: list[Path]) -> bool:
|
|
165
|
+
shared = home / ".claude" / "projects"
|
|
166
|
+
if not shared.is_dir() or any(not same_history(d / "projects", shared) for d in accounts):
|
|
167
|
+
return False
|
|
168
|
+
for path in [home / ".claude" / "settings.json", *(d / "settings.json" for d in accounts)]:
|
|
169
|
+
try:
|
|
170
|
+
if json.loads(path.read_text())["permissions"]["defaultMode"] != "bypassPermissions":
|
|
171
|
+
return False
|
|
172
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
173
|
+
return False
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main() -> int:
|
|
178
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
179
|
+
parser.add_argument("verb", choices=("check", "apply"))
|
|
180
|
+
parser.add_argument("--home", type=Path, default=Path.home())
|
|
181
|
+
parser.add_argument("--root", type=Path, default=None)
|
|
182
|
+
args = parser.parse_args()
|
|
183
|
+
home = args.home.resolve()
|
|
184
|
+
root = (args.root or Path(os.environ.get("CLAUDE_ACCOUNTS_ROOT", home / ".claude-accounts"))).resolve()
|
|
185
|
+
if args.verb == "check":
|
|
186
|
+
accounts = account_dirs(root)
|
|
187
|
+
ready = is_ready(home, accounts)
|
|
188
|
+
print(json.dumps({"ready": ready, "accounts": len(accounts)}))
|
|
189
|
+
return 0 if ready else 1
|
|
190
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
191
|
+
descriptor = os.open(root / ".pool-prepare.lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
192
|
+
with os.fdopen(descriptor, "w") as lock:
|
|
193
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
194
|
+
accounts = account_dirs(root)
|
|
195
|
+
settings = [home / ".claude" / "settings.json", *(d / "settings.json" for d in accounts)]
|
|
196
|
+
validate_settings(settings)
|
|
197
|
+
moved, conflicts = prepare_history(home, root, accounts)
|
|
198
|
+
changed = sum(set_default_mode(path) for path in settings)
|
|
199
|
+
ready = is_ready(home, accounts)
|
|
200
|
+
print(json.dumps({"ready": ready, "accounts": len(accounts), "files_moved": moved,
|
|
201
|
+
"conflicts_archived": conflicts, "settings_updated": changed}))
|
|
202
|
+
return 0 if ready else 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|
package/lib/common.sh
CHANGED
|
@@ -781,8 +781,14 @@ PYEOF
|
|
|
781
781
|
if [ ! -e "$d/CLAUDE.md" ] && [ -f "$HOME/.claude/CLAUDE.md" ]; then
|
|
782
782
|
ln -s "$HOME/.claude/CLAUDE.md" "$d/CLAUDE.md" 2>/dev/null || true
|
|
783
783
|
fi
|
|
784
|
-
|
|
785
|
-
|
|
784
|
+
# A fresh machine may seed pooled accounts before stock Claude has ever created
|
|
785
|
+
# ~/.claude/projects. Create the shared tree first or each account will create
|
|
786
|
+
# its own private history, making --resume depend on the next random pick.
|
|
787
|
+
if [ ! -e "$d/projects" ] && [ ! -L "$d/projects" ]; then
|
|
788
|
+
mkdir -p -m 700 "$HOME/.claude" "$HOME/.claude/projects" 2>/dev/null || true
|
|
789
|
+
if [ "$d" != "$HOME/.claude" ] && [ -d "$HOME/.claude/projects" ]; then
|
|
790
|
+
ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
|
|
791
|
+
fi
|
|
786
792
|
fi
|
|
787
793
|
seed_mcp_registry "$d"
|
|
788
794
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.54",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
59
|
"test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
|
|
61
|
-
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
|
|
61
|
+
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 tests/test_mcp_registry.py && npm run test:codex-settings",
|
|
62
62
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
63
63
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
64
64
|
},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -87,6 +87,19 @@ class AccountSeedingTests(unittest.TestCase):
|
|
|
87
87
|
self.seed(provider)
|
|
88
88
|
self.assertEqual((account / name).read_text(), original)
|
|
89
89
|
|
|
90
|
+
def test_claude_history_is_shared_even_before_stock_claude_runs(self):
|
|
91
|
+
shared = self.home / ".claude" / "projects"
|
|
92
|
+
self.assertFalse(shared.exists())
|
|
93
|
+
first = self.seed("claude")
|
|
94
|
+
second = self.seed("claude", self.home / ".claude-accounts" / "acct-02")
|
|
95
|
+
self.assertTrue(shared.is_dir())
|
|
96
|
+
self.assertEqual((first / "projects").resolve(), shared.resolve())
|
|
97
|
+
self.assertEqual((second / "projects").resolve(), shared.resolve())
|
|
98
|
+
session = first / "projects" / "-work" / "session.jsonl"
|
|
99
|
+
session.parent.mkdir()
|
|
100
|
+
session.write_text("session\n")
|
|
101
|
+
self.assertEqual((second / "projects" / "-work" / "session.jsonl").read_text(), "session\n")
|
|
102
|
+
|
|
90
103
|
def test_local_sync_repairs_only_manifest_accounts(self):
|
|
91
104
|
for provider, name, _ in PROVIDERS:
|
|
92
105
|
with self.subTest(provider=provider):
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The local Claude pool repair retains every transcript and existing setting."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SCRIPT = Path(__file__).resolve().parents[1] / "lib" / "claude_pool_prepare.py"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClaudePoolPrepareTests(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.work = tempfile.TemporaryDirectory()
|
|
19
|
+
self.addCleanup(self.work.cleanup)
|
|
20
|
+
self.home = Path(self.work.name)
|
|
21
|
+
self.root = self.home / ".claude-accounts"
|
|
22
|
+
self.root.mkdir()
|
|
23
|
+
self.accounts = [self.root / f"acct-{number:02d}" for number in (1, 2)]
|
|
24
|
+
for account in self.accounts:
|
|
25
|
+
account.mkdir()
|
|
26
|
+
(self.root / "accounts.json").write_text(json.dumps({
|
|
27
|
+
"accounts": [{"id": account.name} for account in self.accounts],
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
def run_prepare(self, verb):
|
|
31
|
+
return subprocess.run([sys.executable, str(SCRIPT), verb, "--home", str(self.home)],
|
|
32
|
+
capture_output=True, text=True)
|
|
33
|
+
|
|
34
|
+
def test_fresh_pool_starts_shared_and_bypassed(self):
|
|
35
|
+
self.assertEqual(self.run_prepare("check").returncode, 1)
|
|
36
|
+
result = self.run_prepare("apply")
|
|
37
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
38
|
+
self.assertEqual(json.loads(result.stdout)["accounts"], 2)
|
|
39
|
+
shared = self.home / ".claude" / "projects"
|
|
40
|
+
self.assertTrue(shared.is_dir())
|
|
41
|
+
for account in self.accounts:
|
|
42
|
+
self.assertTrue((account / "projects").is_symlink())
|
|
43
|
+
self.assertEqual((account / "projects").resolve(), shared.resolve())
|
|
44
|
+
self.assertEqual(json.loads((account / "settings.json").read_text())
|
|
45
|
+
["permissions"]["defaultMode"], "bypassPermissions")
|
|
46
|
+
self.assertEqual(self.run_prepare("check").returncode, 0)
|
|
47
|
+
|
|
48
|
+
def test_split_history_merges_and_preserves_conflicting_transcripts(self):
|
|
49
|
+
first, second = self.accounts
|
|
50
|
+
first_session = first / "projects" / "-work" / "session.jsonl"
|
|
51
|
+
second_session = second / "projects" / "-work" / "session.jsonl"
|
|
52
|
+
for session, content, stamp in ((first_session, "old\n", 10),
|
|
53
|
+
(second_session, "new\n", 20)):
|
|
54
|
+
session.parent.mkdir(parents=True)
|
|
55
|
+
session.write_text(content)
|
|
56
|
+
os.utime(session, (stamp, stamp))
|
|
57
|
+
(first / "projects" / "-work" / "first.jsonl").write_text("first\n")
|
|
58
|
+
(second / "projects" / "-work" / "second.jsonl").write_text("second\n")
|
|
59
|
+
(first / "settings.json").write_text(json.dumps({
|
|
60
|
+
"model": "opus", "permissions": {"defaultMode": "auto", "allow": ["Bash"]}}))
|
|
61
|
+
result = self.run_prepare("apply")
|
|
62
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
63
|
+
self.assertEqual(json.loads(result.stdout)["conflicts_archived"], 1)
|
|
64
|
+
for account in self.accounts:
|
|
65
|
+
self.assertEqual((account / "projects" / "-work" / "session.jsonl").read_text(), "new\n")
|
|
66
|
+
self.assertTrue((account / "projects" / "-work" / "first.jsonl").exists())
|
|
67
|
+
self.assertTrue((account / "projects" / "-work" / "second.jsonl").exists())
|
|
68
|
+
self.assertEqual((self.root / "history-repair" / "conflicts" / "acct-02" / "-work" /
|
|
69
|
+
"session.jsonl").read_text(), "old\n")
|
|
70
|
+
settings = json.loads((first / "settings.json").read_text())
|
|
71
|
+
self.assertEqual(settings["model"], "opus")
|
|
72
|
+
self.assertEqual(settings["permissions"]["allow"], ["Bash"])
|
|
73
|
+
self.assertEqual(settings["permissions"]["defaultMode"], "bypassPermissions")
|
|
74
|
+
self.assertEqual(json.loads(self.run_prepare("apply").stdout)["files_moved"], 0)
|
|
75
|
+
|
|
76
|
+
def test_custom_history_link_is_not_replaced(self):
|
|
77
|
+
custom = self.home / "custom-projects"
|
|
78
|
+
custom.mkdir()
|
|
79
|
+
(self.accounts[0] / "projects").symlink_to(custom)
|
|
80
|
+
result = self.run_prepare("apply")
|
|
81
|
+
self.assertNotEqual(result.returncode, 0)
|
|
82
|
+
self.assertEqual((self.accounts[0] / "projects").resolve(), custom.resolve())
|
|
83
|
+
|
|
84
|
+
def test_private_history_waits_for_active_pool_session(self):
|
|
85
|
+
projects = self.accounts[0] / "projects"
|
|
86
|
+
projects.mkdir()
|
|
87
|
+
sessions = self.accounts[1] / "sessions"
|
|
88
|
+
sessions.mkdir()
|
|
89
|
+
(sessions / f"{os.getpid()}.json").write_text("{}")
|
|
90
|
+
result = self.run_prepare("apply")
|
|
91
|
+
self.assertNotEqual(result.returncode, 0)
|
|
92
|
+
self.assertTrue(projects.is_dir())
|
|
93
|
+
self.assertFalse(projects.is_symlink())
|
|
94
|
+
self.assertFalse((self.home / ".claude" / "projects").exists())
|
|
95
|
+
|
|
96
|
+
def test_custom_settings_link_is_not_replaced(self):
|
|
97
|
+
custom = self.home / "settings.json"
|
|
98
|
+
custom.write_text("{}")
|
|
99
|
+
(self.accounts[0] / "settings.json").symlink_to(custom)
|
|
100
|
+
result = self.run_prepare("apply")
|
|
101
|
+
self.assertNotEqual(result.returncode, 0)
|
|
102
|
+
self.assertTrue((self.accounts[0] / "settings.json").is_symlink())
|
|
103
|
+
self.assertFalse((self.home / ".claude" / "projects").exists())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|