claude-multiacc 2.0.57 → 2.0.59

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 (150) hide show
  1. package/README.md +111 -3
  2. package/bin/claude +323 -84
  3. package/bin/claude-accounts +273 -42
  4. package/bin/codex +15 -0
  5. package/bin/codex-accounts +31 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +30 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +82 -10
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +131 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/model_catalog.json +404 -0
  59. package/lib/model_catalog.py +201 -0
  60. package/lib/model_match.py +104 -0
  61. package/lib/report.py +8 -5
  62. package/lib/session_settings.py +123 -0
  63. package/package.json +2 -2
  64. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  121. package/tests/fake_codex_tui.py +25 -0
  122. package/tests/fake_security.sh +24 -2
  123. package/tests/fixtures/cross_provider_cli.py +111 -1
  124. package/tests/run-tests.sh +1028 -23
  125. package/tests/test_autoresume.py +215 -8
  126. package/tests/test_autoresume_catalog.py +226 -0
  127. package/tests/test_autoresume_catalog_cli.py +185 -0
  128. package/tests/test_autoresume_codex_records.py +16 -0
  129. package/tests/test_autoresume_codex_trust.py +247 -0
  130. package/tests/test_autoresume_cross_guards.py +28 -0
  131. package/tests/test_autoresume_cross_origin.py +70 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +67 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_packaged_commands.py +41 -0
@@ -83,6 +83,17 @@ def cc_quota(ts, rtype="five_hour", resets=None, **extra):
83
83
  return rec
84
84
 
85
85
 
86
+ def cc_concurrents(ts):
87
+ """claude 2.1.304's record of the account turning away a request among too many in
88
+ flight at once (2026-10-20): the generic usage-limit words, and no reset."""
89
+ info = {"status": "rejected", "rateLimitType": "concurrents", "isUsingOverage": False}
90
+ return cc_api_error(ts, "rate_limit", "You've hit your usage limit", apiErrorStatus=429,
91
+ apiError="usage_limit_reached",
92
+ apiErrorParams={"rate_limit_info": info},
93
+ quotaLimits=dict(info, unifiedRateLimitFallbackAvailable=False,
94
+ upgradePaths=["overage"]))
95
+
96
+
86
97
  def cc_system(ts):
87
98
  return cc_base(ts, "system", subtype="informational", level="notice", isMeta=False,
88
99
  content="Usage limit reached · continuing automatically at 3:36am "
@@ -104,6 +115,17 @@ def cc_reply(ts, text="Done.", **extra):
104
115
  return rec
105
116
 
106
117
 
118
+ def cc_slash(ts, name="model"):
119
+ """What a slash command run at the prompt leaves in the main transcript (claude 2.1.303,
120
+ /model in sessions 5 and 8 on 2026-10-19): a caveat, the command, its output."""
121
+ return [cc_user(ts, "<local-command-caveat>Caveat: The messages below were generated by "
122
+ "the user while running local commands.</local-command-caveat>",
123
+ isMeta=True),
124
+ cc_user(ts, "<command-name>/%s</command-name>\n<command-message>%s</command-message>"
125
+ "\n<command-args></command-args>" % (name, name)),
126
+ cc_user(ts, "<local-command-stdout>Kept model as Opus 5.5</local-command-stdout>")]
127
+
128
+
107
129
  # ---- codex rollout records (codex 0.156.0 shapes) ------------------------------------
108
130
 
109
131
  def cx_meta(ts, sid=CX_SID, session_id=None, cwd="/w", originator="codex-tui"):
@@ -217,6 +239,16 @@ class ClaudeClassifierTests(unittest.TestCase):
217
239
  self.assertEqual(self.verdict(cc_quota(self.T, rtype, resets=self.T + 60))["class"],
218
240
  "model", rtype)
219
241
 
242
+ def test_a_concurrency_throttle_is_transient(self):
243
+ # 2026-10-20: read as one model's limit, it kept two sessions off a serving account
244
+ # for five hours; the account served 700+ requests a minute around those 54
245
+ v = self.verdict(cc_concurrents(self.T))
246
+ self.assertEqual((v["class"], v["code"], v["reset"], v["rtype"]),
247
+ ("transient", "rate_limit", None, ""))
248
+ # an agent's is its own: it never moves the session
249
+ self.assertIsNone(ar.classify_claude_sidechain(dict(cc_concurrents(self.T),
250
+ isSidechain=True)))
251
+
220
252
  def test_model_limit_without_quota_limits(self):
221
253
  for text in ("You've reached your Fable limit. Run /usage-credits to keep going.",
222
254
  "You've reached your Fable limit. Switch to another model to continue."):
@@ -263,9 +295,26 @@ class ClaudeClassifierTests(unittest.TestCase):
263
295
  def test_what_counts_as_the_user_typing(self):
264
296
  cancel = ("cancel", "user")
265
297
  self.assertEqual(ar.classify_claude(cc_user(self.T, "please continue")), cancel)
298
+ # a slash command that only shows or configures the session is the user too, but one
299
+ # the tracker tells apart (it ends only a model-scoped or transient verdict), in
300
+ # either order the client writes its tags
301
+ for text in ("<command-name>/model</command-name>\n<command-args></command-args>",
302
+ "<command-message>model</command-message>\n<command-name>/model"
303
+ "</command-name>",
304
+ [{"type": "text", "text": " <command-name>/usage</command-name>"}],
305
+ "<command-name>/Effort</command-name>\n<command-args>max</command-args>"):
306
+ self.assertEqual(ar.classify_claude(cc_user(self.T, text)), ("cancel", "command"))
307
+ for name in ("model", "effort", "mcp", "status", "context", "cost"):
308
+ self.assertEqual(ar.classify_claude(cc_slash(self.T, name)[1]), ("cancel", "command"))
309
+ # any other command may be what fixed the account: the user typing, as before
310
+ for name in ("login", "limit-reset", "usage-credits", "extra-usage", "upgrade",
311
+ "rate-limit-options", "config", "fast", "compact", "brand-new-command"):
312
+ self.assertEqual(ar.classify_claude(cc_slash(self.T, name)[1]), cancel, name)
313
+ for text in ("<command-message>model</command-message>", "<command-name></command-name>",
314
+ "<command-name>/model</command-name"):
315
+ self.assertEqual(ar.classify_claude(cc_user(self.T, text)), cancel, text)
266
316
  self.assertEqual(ar.classify_claude(cc_user(
267
- self.T, "<command-name>/model</command-name>\n<command-args></command-args>")),
268
- cancel)
317
+ self.T, "what does <command-name> mean here?")), cancel)
269
318
  self.assertEqual(ar.classify_claude(cc_user(
270
319
  self.T, [{"type": "text", "text": "look at this"},
271
320
  {"type": "image", "source": {"type": "base64", "data": "AA=="}}])), cancel)
@@ -281,7 +330,7 @@ class ClaudeClassifierTests(unittest.TestCase):
281
330
  cc_user(self.T, "This session is being continued from a previous conversation",
282
331
  isCompactSummary=True),
283
332
  cc_user(self.T, []),
284
- ]
333
+ ] + [cc_slash(self.T)[0], cc_slash(self.T)[2]]
285
334
  for rec in not_human:
286
335
  self.assertIsNone(ar.classify_claude(rec), rec["message"])
287
336
 
@@ -334,6 +383,56 @@ class ClaudeTrackerTests(unittest.TestCase):
334
383
  self.assertIsNone(tr.feed(cc_user(self.LAUNCHED - 60, "old"), 3))
335
384
  self.assertIsNotNone(tr.pending)
336
385
 
386
+ def test_a_slash_command_ends_only_a_model_scoped_or_transient_verdict(self):
387
+ # the ACCOUNT's verdicts outlive /model (sessions 5 and 8, 2026-10-19): it is just as
388
+ # limited afterwards
389
+ for err in (cc_quota(self.LAUNCHED + 1, resets=self.LAUNCHED + 99),
390
+ cc_quota(self.LAUNCHED + 1, "seven_day"),
391
+ cc_api_error(self.LAUNCHED + 1, "authentication_failed"),
392
+ cc_api_error(self.LAUNCHED + 1, "oauth_org_not_allowed"),
393
+ cc_api_error(self.LAUNCHED + 1, "billing_error")):
394
+ with self.subTest(verdict=err["error"]):
395
+ tr = self.tracker()
396
+ tr.feed(err, 1)
397
+ pending = tr.pending
398
+ self.assertIn(pending["class"], ar.ACCOUNT_CLASSES)
399
+ for rec in cc_slash(self.LAUNCHED + 2) + cc_slash(self.LAUNCHED + 3, "usage"):
400
+ self.assertIsNone(tr.feed(rec, 2))
401
+ self.assertEqual((tr.pending, tr.pending_since, tr.cleared), (pending, 1, 0.0))
402
+ # a prompt the user types still ends it, as before
403
+ self.assertEqual(tr.feed(cc_user(self.LAUNCHED + 4, "wait, I'll switch"), 4),
404
+ ("cancel", "user"))
405
+ self.assertIsNone(tr.pending)
406
+ # /model is how one handles a MODEL's limit; a failed turn ends with any input
407
+ for err in (cc_quota(self.LAUNCHED + 1, "seven_day_opus"),
408
+ cc_api_error(self.LAUNCHED + 1, "rate_limit", "You've reached your Fable "
409
+ "limit. Switch to another model to continue."),
410
+ cc_api_error(self.LAUNCHED + 1, "overloaded")):
411
+ with self.subTest(verdict=err["error"]):
412
+ tr = self.tracker()
413
+ tr.feed(err, 1)
414
+ self.assertIn(tr.pending["class"], ("model", "transient"))
415
+ self.assertEqual(tr.feed(cc_slash(self.LAUNCHED + 2)[1], 2), ("cancel", "command"))
416
+ self.assertIsNone(tr.pending)
417
+
418
+ def test_a_command_that_can_fix_the_account_ends_its_verdict(self):
419
+ # "Not logged in · Please run /login": /login, or /limit-reset after the limit, is
420
+ # the user fixing the ACCOUNT — like a typed prompt, and like any command not known
421
+ # to leave it as it was
422
+ for err, name in ((cc_api_error(self.LAUNCHED + 1, "authentication_failed",
423
+ "Not logged in · Please run /login"), "login"),
424
+ (cc_quota(self.LAUNCHED + 1, resets=self.LAUNCHED + 99), "limit-reset"),
425
+ (cc_quota(self.LAUNCHED + 1, "seven_day"), "extra-usage"),
426
+ (cc_api_error(self.LAUNCHED + 1, "oauth_org_not_allowed"), "login"),
427
+ (cc_quota(self.LAUNCHED + 1, resets=self.LAUNCHED + 99), "plugin:fix")):
428
+ with self.subTest(verdict=err["error"], command=name):
429
+ tr = self.tracker()
430
+ tr.feed(err, 1)
431
+ self.assertIsNone(tr.feed(cc_slash(self.LAUNCHED + 2, name)[0], 2)) # the caveat
432
+ self.assertEqual(tr.feed(cc_slash(self.LAUNCHED + 2, name)[1], 2),
433
+ ("cancel", "user"))
434
+ self.assertIsNone(tr.pending)
435
+
337
436
  def test_pending_since_restarts_only_on_a_class_change(self):
338
437
  tr = self.tracker()
339
438
  tr.feed(cc_quota(self.LAUNCHED + 1), 10)
@@ -506,8 +605,8 @@ class ArgvTests(unittest.TestCase):
506
605
  self.assertEqual(self.rl("claude", []), ["--resume", SID, p])
507
606
 
508
607
  def test_claude_model_is_only_the_launchs_own(self):
509
- # Claude restores the session's model on --resume ([1m] included); an added
510
- # --model would switch that off. An explicit one from the launch is kept as is.
608
+ # Claude restores the session's model on --resume ([1m] included), so no --model is
609
+ # added, and an explicit one from the launch is kept as is.
511
610
  p = ar.resume_prompt("model")
512
611
  self.assertEqual(self.rl("claude", ["hello there"], "model"), ["--resume", SID, p])
513
612
  self.assertEqual(self.rl("claude", ["--model", "sonnet"], "model"),
@@ -1248,6 +1347,40 @@ class ClaudeWatcherTests(PoolCase):
1248
1347
  self.assertTrue(kv["hist"].startswith("quota:%d,auth:" % (self.t0 - 7200)))
1249
1348
  self.assertIn("chain=ChainId1", self.log_lines()[0])
1250
1349
 
1350
+ def test_the_relaunch_names_the_credential_its_run_carried(self):
1351
+ # The shim launched this run on the portable token (via=token in its state). /clear
1352
+ # then has the same process run a new session id, which no capture of the shim ever
1353
+ # tagged: its auth failure is still the token's, and the relaunch file says so.
1354
+ state = self.write_state(["-c"])
1355
+ with open(state, "a") as fh:
1356
+ fh.write("via=token\n")
1357
+ self.registry()
1358
+ self.transcript([cc_user(self.launched + 1, "hello")])
1359
+ w, r = self.watcher(state)
1360
+ r.at(self.t0 + 1, lambda: self.registry(sid=SID2))
1361
+ r.at(self.t0 + 3, lambda: self.transcript([cc_api_error(
1362
+ self.t0 + 3, "authentication_failed", "Not logged in · Please run /login")], sid=SID2))
1363
+ w.run()
1364
+ kv, _ = self.relaunch_of(r)
1365
+ self.assertEqual((kv["class"], kv["sid"], kv["via"]), ("auth", SID2, "token"))
1366
+
1367
+ def test_the_state_names_a_credential_or_nothing(self):
1368
+ for raw, want in (("token", "token"), ("login", "login"), ("", ""), ("TOKEN", ""),
1369
+ ("token x", "")):
1370
+ state = self.write_state(["-c"])
1371
+ with open(state, "a") as fh:
1372
+ fh.write("via=%s\n" % raw)
1373
+ self.assertEqual(ar.load_state(state)["via"], want, raw)
1374
+ # a shim from before the field: nothing is named, and the relaunch names nothing
1375
+ state = self.write_state(["-c"])
1376
+ self.assertEqual(ar.load_state(state)["via"], "")
1377
+ self.registry()
1378
+ self.transcript([cc_api_error(self.t0, "authentication_failed", "Not logged in")])
1379
+ w, r = self.watcher(state)
1380
+ w.run()
1381
+ kv, _ = self.relaunch_of(r)
1382
+ self.assertNotIn("via", kv)
1383
+
1251
1384
  def test_old_rejection_in_a_resumed_transcript_does_not_fire(self):
1252
1385
  state = self.write_state(["--resume", SID])
1253
1386
  self.registry()
@@ -1314,6 +1447,68 @@ class ClaudeWatcherTests(PoolCase):
1314
1447
  self.assertEqual(len(r.probes), 1)
1315
1448
  self.assertEqual((r.signals, r.sends, self.relaunch_files()), ([], [], []))
1316
1449
 
1450
+ def test_a_slash_command_moves_on_only_from_a_model_scoped_limit(self):
1451
+ reset = int(self.t0) + 999
1452
+ for err, moved in ((cc_quota(self.t0, resets=reset), True),
1453
+ (cc_api_error(self.t0, "rate_limit", "You've reached your Fable "
1454
+ "limit. Switch to another model"), False)):
1455
+ with self.subTest(moved=moved):
1456
+ log = self.root / "selection.log"
1457
+ if log.exists():
1458
+ log.unlink()
1459
+ state = self.write_state([])
1460
+ reg = self.registry()
1461
+ tpath = self.transcript([err])
1462
+ w, r = self.watcher(state)
1463
+ # the user runs /model before GRACE runs out, and later leaves with /exit
1464
+ r.at(self.t0 + 1.5, lambda tpath=tpath: append(tpath, cc_slash(self.t0 + 1)))
1465
+ r.at(self.t0 + 30, lambda reg=reg, r=r: (reg.unlink() if reg.exists() else None,
1466
+ r.exit(self.PID)))
1467
+ self.assertEqual(w.run(), 0)
1468
+ self.assertEqual(len(r.probes), int(moved))
1469
+ self.assertEqual(self.events(), ["watch", "detect"] + ["switch"] * moved)
1470
+ if moved:
1471
+ self.assertEqual(self.relaunch_of(r)[0]["class"], "quota")
1472
+ tpath.unlink()
1473
+
1474
+ def test_a_command_that_fixes_the_account_keeps_the_session(self):
1475
+ # /login 2 s after "Not logged in · Please run /login" (the TUI must not be stopped
1476
+ # mid-login, nor the account being logged back in parked); /limit-reset while the
1477
+ # watcher holds for want of room, before room appears; a watcher restarted onto a
1478
+ # limit the user reset half an hour ago
1479
+ reset = int(self.t0) + 9000
1480
+ cases = ((cc_api_error(self.t0, "authentication_failed",
1481
+ "Not logged in · Please run /login"), "login", 2, False),
1482
+ (cc_quota(self.t0, resets=reset), "limit-reset", 8, False),
1483
+ (cc_quota(self.t0 - 1800, resets=reset), "limit-reset", -1790, True))
1484
+ for err, name, after, restarted in cases:
1485
+ with self.subTest(command=name, restarted=restarted):
1486
+ log = self.root / "selection.log"
1487
+ if log.exists():
1488
+ log.unlink()
1489
+ self.launched = int(self.t0) - (3600 if restarted else 5)
1490
+ state = self.write_state([])
1491
+ reg = self.registry()
1492
+ tpath = self.transcript([cc_user(self.launched + 1, "go"),
1493
+ cc_reply(self.launched + 2, "ok"), err]
1494
+ + (cc_slash(self.t0 + after, name) if after < 0 else []))
1495
+ w, r = self.watcher(state, {"CLAUDE_MULTIACC_AR_HOLD_REPROBE": "10"})
1496
+ held = name == "limit-reset" and not restarted
1497
+ if held:
1498
+ r.probe_out = "pick=acct-01 tier=soft\n" # nowhere to go: hold
1499
+ r.at(self.t0 + 9, lambda r=r: setattr(r, "probe_out",
1500
+ "pick=acct-02 tier=eligible\n"))
1501
+ if after > 0:
1502
+ r.at(self.t0 + after, lambda tpath=tpath, at=self.t0 + after, name=name:
1503
+ append(tpath, cc_slash(at, name)))
1504
+ r.at(self.t0 + 40, lambda reg=reg, r=r: (reg.unlink() if reg.exists() else None,
1505
+ r.exit(self.PID)))
1506
+ self.assertEqual(w.run(), 0)
1507
+ self.assertEqual((len(r.probes), r.signals, r.sends, self.relaunch_files()),
1508
+ (int(held), [], [], []))
1509
+ self.assertEqual(self.events(), ["watch", "detect"] + ["hold"] * held)
1510
+ tpath.unlink()
1511
+
1317
1512
  def test_kill_switch_file_reaches_a_running_watcher(self):
1318
1513
  state = self.write_state([])
1319
1514
  self.registry()
@@ -1354,12 +1549,14 @@ class ClaudeWatcherTests(PoolCase):
1354
1549
  self.assertEqual(w.run(), 0)
1355
1550
  self.assertEqual((r.probes, r.signals), ([], []))
1356
1551
  kv, argv = self.relaunch_of(r)
1552
+ # the transcript's last reply names its model and no effort: Claude restores the model
1357
1553
  self.assertEqual(argv, ["--dangerously-skip-permissions", "--resume", SID,
1358
1554
  ar.resume_prompt("crash")])
1359
1555
  self.assertEqual((kv["class"], kv["acct"], kv["avoid"], kv["depth"]),
1360
1556
  ("crash", "acct-01", "acct-05:%d" % (self.t0 + 900), "1"))
1361
1557
  self.assert_sequence(r)
1362
1558
  self.assertEqual(self.events(), ["watch", "crash", "switch"])
1559
+ self.assertTrue(self.log_lines()[-1].endswith(" probe=-"))
1363
1560
 
1364
1561
  def test_a_clean_exit_or_an_early_death_is_final(self):
1365
1562
  state = self.write_state([])
@@ -2273,9 +2470,12 @@ class CliTests(PoolCase):
2273
2470
  timeout=timeout, universal_newlines=True)
2274
2471
 
2275
2472
  def test_classify(self):
2276
- path = self.transcript([cc_quota(self.t0 - 10000, resets=1790123817),
2473
+ # Resets in the future (the classifier drops a quota verdict whose reset has passed,
2474
+ # as the watcher does: the last case below).
2475
+ week = future_reset(self.t0)
2476
+ path = self.transcript([cc_quota(self.t0 - 10000, resets=week - 86400),
2277
2477
  cc_api_error(self.t0, "invalid_request"),
2278
- cc_quota(self.t0, "seven_day", resets=1790372787),
2478
+ cc_quota(self.t0, "seven_day", resets=week),
2279
2479
  cc_user(self.t0 + 1, "go"),
2280
2480
  cc_api_error(self.t0 + 2, "authentication_failed")])
2281
2481
  out = self.cli("classify", "--provider", "claude", str(path),
@@ -2285,10 +2485,17 @@ class CliTests(PoolCase):
2285
2485
  self.assertEqual([e["kind"] for e in doc["events"]],
2286
2486
  ["never", "verdict", "cancel", "verdict"])
2287
2487
  self.assertEqual((doc["events"][1]["class"], doc["events"][1]["rtype"],
2288
- doc["events"][1]["reset"]), ("quota", "seven_day", 1790372787))
2488
+ doc["events"][1]["reset"]), ("quota", "seven_day", week))
2289
2489
  self.assertEqual(doc["pending"]["class"], "auth")
2290
2490
  doc = json.loads(self.cli("classify", "--provider", "claude", str(path)).stdout)
2291
2491
  self.assertEqual(doc["events"][0]["kind"], "verdict") # no --since: everything counts
2492
+ # a limit whose reset has passed is no verdict; one with no reset still is
2493
+ lifted = self.tmp / "lifted.jsonl"
2494
+ lifted.write_text(jl([cc_quota(self.t0 - 600, resets=int(self.t0) - 60),
2495
+ cc_quota(self.t0 - 500)]))
2496
+ doc = json.loads(self.cli("classify", "--provider", "claude", str(lifted)).stdout)
2497
+ self.assertEqual([(e["kind"], e["class"], e["reset"]) for e in doc["events"]],
2498
+ [("verdict", "quota", None)])
2292
2499
 
2293
2500
  def test_classify_codex(self):
2294
2501
  reset = future_reset(self.t0)
@@ -0,0 +1,226 @@
1
+ """lib/model_catalog.py and lib/model_match.py: the catalog and the level matching."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(ROOT / "lib"))
14
+ import model_catalog as mc # noqa: E402
15
+ import model_match as mm # noqa: E402
16
+
17
+ LEVELS = ["low", "medium", "high", "xhigh", "max"]
18
+ CODEX_LEVELS = LEVELS + ["ultra"]
19
+
20
+
21
+ def entry(family, version, efforts, default, listed=True, upgrade=None):
22
+ return {"family": family, "version": version, "efforts": efforts, "default_effort": default,
23
+ "listed": listed, "upgrade": upgrade}
24
+
25
+
26
+ CATALOG = {"schema": 1,
27
+ "claude": {"efforts": LEVELS, "families": {"haiku": 2, "sonnet": 3, "opus": 4, "fable": 5, "mythos": 5},
28
+ "models": {"claude-fable-5-1": entry("fable", [5, 1], LEVELS, "high"),
29
+ "claude-opus-5-5": entry("opus", [5, 5], LEVELS, "medium"),
30
+ "claude-sonnet-5-5": entry("sonnet", [5, 5], LEVELS, "medium"),
31
+ "claude-haiku-5-5": entry("haiku", [5, 5], LEVELS, "medium"),
32
+ "claude-opus-4-6": entry("opus", [4, 6], LEVELS[:3] + ["max"], "high", False),
33
+ "claude-sonnet-4-6": entry("sonnet", [4, 6], LEVELS[:3] + ["max"], "high", False)}},
34
+ "codex": {"efforts": ["none", "minimal"] + CODEX_LEVELS,
35
+ "families": {"luna": 1, "terra": 2, "sol": 3, "astra": 4},
36
+ "models": {"gpt-6.1-sol": entry("sol", [6, 1], CODEX_LEVELS, "low"),
37
+ "gpt-6-astra": entry("astra", [6], CODEX_LEVELS, "medium"),
38
+ "gpt-6-sol": entry("sol", [6], CODEX_LEVELS, "medium"),
39
+ "gpt-6-luna": entry("luna", [6], LEVELS, "medium"),
40
+ "gpt-5.6-terra": entry("terra", [5, 6], CODEX_LEVELS, "medium"),
41
+ "gpt-5.5": entry(None, [5, 5], LEVELS[:4], "medium", False, "gpt-6.1-sol")}}}
42
+
43
+
44
+ class MatchTests(unittest.TestCase):
45
+ def match(self, source, model, effort, target):
46
+ got = mm.resume_settings(source, {"model": model, "effort": effort}, target, CATALOG)
47
+ return got["model"], got["effort"]
48
+
49
+ def test_current_lineup_is_the_newest_listed_model_per_family(self):
50
+ # terra's newest is a 5.x: still the terra level (launches are staggered)
51
+ self.assertEqual(mm.current_models(CATALOG, "codex"),
52
+ {"sol": "gpt-6.1-sol", "astra": "gpt-6-astra", "luna": "gpt-6-luna", "terra": "gpt-5.6-terra"})
53
+ self.assertEqual(mm.current_models(CATALOG, "claude"),
54
+ {"fable": "claude-fable-5-1", "opus": "claude-opus-5-5", "sonnet": "claude-sonnet-5-5",
55
+ "haiku": "claude-haiku-5-5"})
56
+
57
+ def test_models_match_by_level(self):
58
+ # the operator's map (2026-10-14): astra -> opus, sol -> sonnet, terra and luna -> haiku ...
59
+ self.assertEqual(self.match("codex", "gpt-6-astra", "ultra", "claude"), ("claude-opus-5-5", "max"))
60
+ self.assertEqual(self.match("codex", "gpt-6.1-sol", "high", "claude"), ("claude-sonnet-5-5", "high"))
61
+ self.assertEqual(self.match("codex", "gpt-6-sol", "xhigh", "claude"), ("claude-sonnet-5-5", "xhigh"))
62
+ self.assertEqual(self.match("codex", "gpt-5.6-terra", "none", "claude"), ("claude-haiku-5-5", "low"))
63
+ self.assertEqual(self.match("codex", "gpt-6-luna", "minimal", "claude"), ("claude-haiku-5-5", "low"))
64
+ # ... and fable, mythos and opus -> astra, sonnet -> sol, haiku -> terra
65
+ self.assertEqual(self.match("claude", "claude-fable-5-1", "max", "codex"), ("gpt-6-astra", "max"))
66
+ self.assertEqual(self.match("claude", "claude-opus-5-5[1m]", "xhigh", "codex"), ("gpt-6-astra", "xhigh"))
67
+ self.assertEqual(self.match("claude", "claude-opus-4-6", "medium", "codex"), ("gpt-6-astra", "medium"))
68
+ self.assertEqual(self.match("claude", "claude-sonnet-5-5", "high", "codex"), ("gpt-6.1-sol", "high"))
69
+ self.assertEqual(self.match("claude", "claude-haiku-5-5", "low", "codex"), ("gpt-5.6-terra", "low"))
70
+ # a retiring model counts as its upgrade; an unknown one is left to the destination
71
+ self.assertEqual(self.match("codex", "gpt-5.5", "xhigh", "claude"), ("claude-sonnet-5-5", "xhigh"))
72
+ self.assertEqual(self.match("codex", "codex-auto-review", "high", "claude"), (None, "high"))
73
+ self.assertEqual(self.match("claude", "claude-mythos-9", "max", "codex"), ("gpt-6-astra", "max"))
74
+ self.assertEqual(self.match("codex", None, None, "claude"), (None, None))
75
+
76
+ def test_effort_is_clamped_to_what_the_destination_model_supports(self):
77
+ # gpt-6-luna has no ultra: max, the nearest level below
78
+ self.assertEqual(mm.match_effort(CATALOG, "claude", "max", "codex", "gpt-6-luna"), "max")
79
+ self.assertEqual(mm.match_effort(CATALOG, "codex", "ultra", "codex", "gpt-6-luna"), "max")
80
+ self.assertEqual(mm.match_effort(CATALOG, "codex", "xhigh", "claude", "claude-opus-4-6"), "high")
81
+ self.assertEqual(mm.match_effort(CATALOG, "codex", "ultra", "claude", "claude-sonnet-4-6"), "max")
82
+ self.assertEqual(mm.match_effort(CATALOG, "claude", "xhigh", "codex", "gpt-5.5"), "xhigh")
83
+ self.assertEqual(mm.match_effort(CATALOG, "codex", "persistent", "claude", "claude-opus-5-5"), None)
84
+ self.assertEqual(mm.match_effort(CATALOG, "claude", "high", "claude", "claude-unknown-9"), "high")
85
+
86
+ def test_same_provider_passes_the_session_through(self):
87
+ self.assertEqual(self.match("claude", "claude-opus-5-5[1m]", "max", "claude"), ("claude-opus-5-5[1m]", "max"))
88
+ self.assertEqual(self.match("claude", "claude-opus-5-5", None, "claude"), ("claude-opus-5-5", None))
89
+ # codex needs both flags or none: an unset effort becomes the model's catalog default
90
+ self.assertEqual(self.match("codex", "gpt-6-astra", None, "codex"), ("gpt-6-astra", "medium"))
91
+ self.assertEqual(self.match("codex", "gpt-unknown", None, "codex"), ("gpt-unknown", None))
92
+
93
+ def test_flags(self):
94
+ self.assertEqual(mm.settings_flags("claude", "claude-opus-5-5[1m]", "max"),
95
+ ["--model", "claude-opus-5-5[1m]", "--effort", "max"])
96
+ self.assertEqual(mm.settings_flags("claude", None, "max"), ["--effort", "max"])
97
+ self.assertEqual(mm.settings_flags("claude", "claude-opus-5-5", None), ["--model", "claude-opus-5-5"])
98
+ self.assertEqual(mm.settings_flags("codex", "gpt-6-astra", "ultra"),
99
+ ["-m", "gpt-6-astra", "-c", "model_reasoning_effort=ultra"])
100
+ self.assertEqual(mm.settings_flags("codex", "gpt-6-astra", None), [])
101
+ self.assertEqual(mm.settings_flags("codex", None, "high"), [])
102
+ self.assertEqual(mm.settings_flags("gemini", "x", "y"), [])
103
+
104
+
105
+ class CatalogTests(unittest.TestCase):
106
+ def setUp(self):
107
+ self.tmp = Path(tempfile.mkdtemp(prefix="cat-"))
108
+ self.addCleanup(shutil.rmtree, str(self.tmp), True)
109
+ self.roots = {"claude": str(self.tmp / "claude"), "codex": str(self.tmp / "codex")}
110
+ for root in self.roots.values():
111
+ os.makedirs(os.path.join(root, "acct-01"))
112
+
113
+ def codex_cache(self, fetched, models, acct="acct-01"):
114
+ doc = {"fetched_at": fetched, "etag": "x", "client_version": "0.162.1", "identity": "y",
115
+ "models": [{"slug": slug, "visibility": "list" if listed else "hide", "priority": 1,
116
+ "default_reasoning_level": default,
117
+ "supported_reasoning_levels": [{"effort": e, "description": ""} for e in efforts],
118
+ "upgrade": {"model": upgrade} if upgrade else None, "model_messages": {"x": "y" * 10}}
119
+ for slug, listed, default, efforts, upgrade in models]}
120
+ path = Path(self.roots["codex"]) / acct / "models_cache.json"
121
+ path.write_text(json.dumps(doc))
122
+ return path
123
+
124
+ def claude_cache(self, fetched_ms, models, name="tok-abc-cc.json"):
125
+ rows = [{"id": mid, "section": section, "thinking": {"type": "effort",
126
+ "effort_options": [{"id": e, "name": e} for e in efforts]}} for mid, section, efforts, _ in models]
127
+ state = {"model": models[0][0], "thinking_by_model": [{"id": mid, "thinking": {"type": "effort", "effort": d}}
128
+ for mid, _, _, d in models]}
129
+ doc = {"version": 2, "fetchedAt": fetched_ms, "staleAt": fetched_ms + 3600000,
130
+ "catalog": {"surface": "cc", "config": {"id": "cc", "models": rows}, "state": state}}
131
+ directory = Path(self.roots["claude"]) / "acct-01" / "cache" / "model-catalog"
132
+ directory.mkdir(parents=True, exist_ok=True)
133
+ (directory / name).write_text(json.dumps(doc))
134
+
135
+ def test_shipped_catalog_is_well_formed(self):
136
+ shipped = json.loads(Path(mc.SHIPPED).read_text())
137
+ for provider in ("claude", "codex"):
138
+ self.assertEqual(shipped[provider]["efforts"][-5:], LEVELS if provider == "claude" else CODEX_LEVELS[-5:])
139
+ self.assertTrue(shipped[provider]["families"])
140
+ self.assertTrue(shipped[provider]["models"])
141
+ for model, item in shipped[provider]["models"].items():
142
+ self.assertEqual(sorted(item), ["default_effort", "efforts", "family", "listed", "upgrade", "version"],
143
+ model)
144
+ current = mm.current_models(shipped, "claude")
145
+ self.assertEqual(sorted(current), ["fable", "haiku", "opus", "sonnet"])
146
+ self.assertEqual(sorted(mm.current_models(shipped, "codex")), ["astra", "luna", "sol", "terra"])
147
+
148
+ def test_live_caches_win_over_the_shipped_file_when_newer(self):
149
+ self.codex_cache("2099-01-01T00:00:00.000000Z",
150
+ [("gpt-9-astra", True, "high", CODEX_LEVELS, None),
151
+ ("gpt-6-astra", False, "medium", CODEX_LEVELS, "gpt-9-astra")])
152
+ self.claude_cache(4102444800000, [("claude-fable-9", "main", LEVELS, "xhigh"),
153
+ ("claude-haiku-5-5", "overflow", LEVELS, "medium")])
154
+ catalog = mc.load(self.roots)
155
+ self.assertEqual(catalog["codex"]["fetched_at"], "2099-01-01T00:00:00Z")
156
+ self.assertEqual(catalog["codex"]["models"]["gpt-9-astra"],
157
+ entry("astra", [9], CODEX_LEVELS, "high"))
158
+ self.assertEqual(catalog["codex"]["models"]["gpt-6-astra"]["upgrade"], "gpt-9-astra")
159
+ self.assertEqual(catalog["claude"]["models"]["claude-fable-9"], entry("fable", [9], LEVELS, "xhigh"))
160
+ self.assertFalse(catalog["claude"]["models"]["claude-haiku-5-5"]["listed"])
161
+ # structure always comes from the shipped file
162
+ self.assertEqual(catalog["claude"]["efforts"], LEVELS)
163
+ self.assertEqual(catalog["codex"]["families"]["astra"], 4)
164
+ self.assertEqual(mm.current_models(catalog, "codex"), {"astra": "gpt-9-astra"})
165
+ got = mm.resume_settings("claude", {"model": "claude-fable-9", "effort": "max"}, "codex", catalog)
166
+ self.assertEqual(got, {"model": "gpt-9-astra", "effort": "max"})
167
+
168
+ def test_older_caches_and_junk_lose_to_the_shipped_file(self):
169
+ self.codex_cache("2000-01-01T00:00:00Z", [("gpt-1-astra", True, "low", LEVELS, None)])
170
+ (Path(self.roots["codex"]) / "acct-01" / "models_cache.json").write_text("{not json")
171
+ self.claude_cache(946684800000, [("claude-opus-1", "main", LEVELS, "high")])
172
+ catalog = mc.load(self.roots)
173
+ shipped = json.loads(Path(mc.SHIPPED).read_text())
174
+ self.assertEqual(catalog["codex"]["models"], shipped["codex"]["models"])
175
+ self.assertEqual(catalog["claude"]["models"], shipped["claude"]["models"])
176
+ self.assertEqual(mc.load(None)["claude"]["models"], shipped["claude"]["models"])
177
+
178
+ def test_refresh_writes_each_pool_root_atomically_and_honours_if_stale(self):
179
+ self.codex_cache("2099-01-01T00:00:00Z", [("gpt-9-astra", True, "high", CODEX_LEVELS, None)])
180
+ written = mc.refresh(self.roots)
181
+ self.assertEqual(sorted(written), sorted(os.path.join(r, mc.OVERLAY) for r in self.roots.values()))
182
+ doc = json.loads(Path(written[0]).read_text())
183
+ self.assertEqual(doc["codex"]["models"]["gpt-9-astra"]["default_effort"], "high")
184
+ self.assertIn("generated_at", doc)
185
+ self.assertEqual(sorted(os.listdir(self.roots["codex"])), ["acct-01", mc.OVERLAY])
186
+ self.assertEqual(mc.refresh(self.roots, if_stale=True), [])
187
+ os.utime(written[0], (0, 0))
188
+ self.assertEqual(mc.refresh(self.roots, if_stale=True), [written[0]])
189
+ # the overlay is itself a source for the next load, until a cache is newer
190
+ shutil.rmtree(os.path.join(self.roots["codex"], "acct-01"))
191
+ self.assertIn("gpt-9-astra", mc.load(self.roots)["codex"]["models"])
192
+ self.assertEqual(mc.refresh({"codex": str(self.tmp / "nope")}), [])
193
+
194
+ def test_an_unset_root_is_not_searched_from_the_cwd(self):
195
+ here = self.tmp / "cwd"
196
+ os.makedirs(str(here / "acct-01"))
197
+ (here / "acct-01" / "models_cache.json").write_text(json.dumps(
198
+ {"fetched_at": "2099-01-01T00:00:00Z", "models": [{"slug": "gpt-9-luna", "visibility": "list"}]}))
199
+ self.addCleanup(os.chdir, os.getcwd())
200
+ os.chdir(str(here))
201
+ shipped = json.loads(Path(mc.SHIPPED).read_text())
202
+ self.assertEqual(mc.load({"claude": self.roots["claude"]})["codex"]["models"], shipped["codex"]["models"])
203
+
204
+ def test_a_failed_write_leaves_no_scratch_file(self):
205
+ os.makedirs(os.path.join(self.roots["codex"], mc.OVERLAY)) # os.replace onto a directory fails
206
+ self.assertEqual(mc.refresh(self.roots), [os.path.join(self.roots["claude"], mc.OVERLAY)])
207
+ self.assertEqual(sorted(os.listdir(self.roots["codex"])), ["acct-01", mc.OVERLAY])
208
+
209
+ def test_cli(self):
210
+ script = str(ROOT / "lib/model_catalog.py")
211
+ out = subprocess.run([sys.executable, "-I", script, "refresh", "--codex-root", self.roots["codex"],
212
+ "--claude-root", self.roots["claude"]], capture_output=True, text=True)
213
+ self.assertEqual(out.returncode, 0, out.stderr)
214
+ self.assertEqual(sorted(out.stdout.split()), sorted(os.path.join(r, mc.OVERLAY) for r in self.roots.values()))
215
+ out = subprocess.run([sys.executable, "-I", script, "show"], capture_output=True, text=True)
216
+ self.assertEqual(out.returncode, 0)
217
+ self.assertEqual(json.loads(out.stdout)["claude"]["efforts"], LEVELS)
218
+ for args in ([], ["bogus"], ["refresh", "--codex-root"], ["refresh", "show"]):
219
+ self.assertEqual(subprocess.run([sys.executable, "-I", script] + args, capture_output=True).returncode, 2)
220
+ out = subprocess.run([sys.executable, "-I", script, "refresh", "--codex-root", str(self.tmp / "nope")],
221
+ capture_output=True, text=True)
222
+ self.assertEqual(out.returncode, 1)
223
+
224
+
225
+ if __name__ == "__main__":
226
+ unittest.main()