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.
Files changed (53) hide show
  1. package/README.md +10 -0
  2. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  3. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  4. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  5. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  6. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  7. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  8. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  25. package/lib/claude_pool_prepare.py +206 -0
  26. package/lib/common.sh +8 -2
  27. package/package.json +2 -2
  28. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  29. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  30. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  31. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  32. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  33. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  34. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  35. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  36. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  37. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  38. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  39. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  40. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  41. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  42. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  43. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  44. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  45. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  46. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  52. package/tests/test_account_seeding.py +13 -0
  53. 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
 
@@ -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
- if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
785
- ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
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.52",
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
  },
@@ -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()