agent-control-plane 0.1.0

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 (317) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +589 -0
  3. package/SKILL.md +149 -0
  4. package/assets/workflow-catalog.json +57 -0
  5. package/bin/audit-issue-routing.sh +74 -0
  6. package/bin/issue-resource-class.sh +58 -0
  7. package/bin/label-follow-up-issues.sh +114 -0
  8. package/bin/pr-risk.sh +532 -0
  9. package/bin/sync-pr-labels.sh +112 -0
  10. package/hooks/heartbeat-hooks.sh +573 -0
  11. package/hooks/issue-reconcile-hooks.sh +217 -0
  12. package/hooks/pr-reconcile-hooks.sh +225 -0
  13. package/npm/bin/agent-control-plane.js +1984 -0
  14. package/npm/public-bin/agent-control-plane +3 -0
  15. package/package.json +61 -0
  16. package/tools/bin/agent-cleanup-worktree +247 -0
  17. package/tools/bin/agent-github-update-labels +66 -0
  18. package/tools/bin/agent-init-worktree +216 -0
  19. package/tools/bin/agent-project-archive-run +52 -0
  20. package/tools/bin/agent-project-capture-worker +46 -0
  21. package/tools/bin/agent-project-catch-up-merged-prs +137 -0
  22. package/tools/bin/agent-project-cleanup-session +244 -0
  23. package/tools/bin/agent-project-detached-launch +107 -0
  24. package/tools/bin/agent-project-heartbeat-loop +2347 -0
  25. package/tools/bin/agent-project-open-issue-worktree +89 -0
  26. package/tools/bin/agent-project-open-pr-worktree +80 -0
  27. package/tools/bin/agent-project-publish-issue-pr +349 -0
  28. package/tools/bin/agent-project-reconcile-issue-session +1128 -0
  29. package/tools/bin/agent-project-reconcile-pr-session +1005 -0
  30. package/tools/bin/agent-project-retry-state +147 -0
  31. package/tools/bin/agent-project-run-claude-session +657 -0
  32. package/tools/bin/agent-project-run-codex-resilient +718 -0
  33. package/tools/bin/agent-project-run-codex-session +316 -0
  34. package/tools/bin/agent-project-run-kilo-session +27 -0
  35. package/tools/bin/agent-project-run-openclaw-session +984 -0
  36. package/tools/bin/agent-project-run-opencode-session +27 -0
  37. package/tools/bin/agent-project-sync-anchor-repo +128 -0
  38. package/tools/bin/agent-project-worker-status +143 -0
  39. package/tools/bin/audit-agent-worktrees.sh +310 -0
  40. package/tools/bin/audit-issue-routing.sh +11 -0
  41. package/tools/bin/audit-retained-layout.sh +58 -0
  42. package/tools/bin/audit-retained-overlap.sh +135 -0
  43. package/tools/bin/audit-retained-worktrees.sh +228 -0
  44. package/tools/bin/branch-verification-guard.sh +351 -0
  45. package/tools/bin/capture-worker.sh +18 -0
  46. package/tools/bin/check-skill-contracts.sh +324 -0
  47. package/tools/bin/cleanup-worktree.sh +44 -0
  48. package/tools/bin/codex-quota +31 -0
  49. package/tools/bin/create-follow-up-issue.sh +114 -0
  50. package/tools/bin/dashboard-launchd-bootstrap.sh +38 -0
  51. package/tools/bin/flow-config-lib.sh +2127 -0
  52. package/tools/bin/flow-resident-worker-lib.sh +683 -0
  53. package/tools/bin/flow-runtime-doctor.sh +97 -0
  54. package/tools/bin/flow-shell-lib.sh +266 -0
  55. package/tools/bin/heartbeat-recovery-preflight.sh +106 -0
  56. package/tools/bin/heartbeat-safe-auto.sh +551 -0
  57. package/tools/bin/install-dashboard-launchd.sh +152 -0
  58. package/tools/bin/install-project-launchd.sh +219 -0
  59. package/tools/bin/issue-publish-scope-guard.sh +242 -0
  60. package/tools/bin/issue-requires-local-workspace-install.sh +31 -0
  61. package/tools/bin/issue-resource-class.sh +12 -0
  62. package/tools/bin/kick-scheduler.sh +75 -0
  63. package/tools/bin/label-follow-up-issues.sh +14 -0
  64. package/tools/bin/new-pr-worktree.sh +50 -0
  65. package/tools/bin/new-worktree.sh +49 -0
  66. package/tools/bin/pr-risk.sh +12 -0
  67. package/tools/bin/prepare-worktree.sh +140 -0
  68. package/tools/bin/profile-activate.sh +109 -0
  69. package/tools/bin/profile-adopt.sh +219 -0
  70. package/tools/bin/profile-smoke.sh +461 -0
  71. package/tools/bin/project-init.sh +189 -0
  72. package/tools/bin/project-launchd-bootstrap.sh +54 -0
  73. package/tools/bin/project-remove.sh +155 -0
  74. package/tools/bin/project-runtime-supervisor.sh +56 -0
  75. package/tools/bin/project-runtimectl.sh +586 -0
  76. package/tools/bin/provider-cooldown-state.sh +166 -0
  77. package/tools/bin/publish-issue-worker.sh +31 -0
  78. package/tools/bin/reconcile-issue-worker.sh +34 -0
  79. package/tools/bin/reconcile-pr-worker.sh +34 -0
  80. package/tools/bin/record-verification.sh +71 -0
  81. package/tools/bin/render-architecture-infographics.sh +110 -0
  82. package/tools/bin/render-dashboard-demo-media.sh +333 -0
  83. package/tools/bin/render-dashboard-snapshot.py +16 -0
  84. package/tools/bin/render-flow-config.sh +86 -0
  85. package/tools/bin/retry-state.sh +31 -0
  86. package/tools/bin/reuse-issue-worktree.sh +75 -0
  87. package/tools/bin/run-codex-bypass.sh +3 -0
  88. package/tools/bin/run-codex-safe.sh +3 -0
  89. package/tools/bin/run-codex-task.sh +231 -0
  90. package/tools/bin/scaffold-profile.sh +374 -0
  91. package/tools/bin/serve-dashboard.sh +5 -0
  92. package/tools/bin/split-retained-slice.sh +124 -0
  93. package/tools/bin/start-issue-worker.sh +796 -0
  94. package/tools/bin/start-pr-fix-worker.sh +458 -0
  95. package/tools/bin/start-pr-merge-repair-worker.sh +8 -0
  96. package/tools/bin/start-pr-review-worker.sh +227 -0
  97. package/tools/bin/start-resident-issue-loop.sh +908 -0
  98. package/tools/bin/sync-agent-repo.sh +52 -0
  99. package/tools/bin/sync-dependency-baseline.sh +247 -0
  100. package/tools/bin/sync-pr-labels.sh +12 -0
  101. package/tools/bin/sync-recurring-issue-checklist.sh +274 -0
  102. package/tools/bin/sync-shared-agent-home.sh +214 -0
  103. package/tools/bin/sync-vscode-workspace.sh +157 -0
  104. package/tools/bin/test-smoke.sh +63 -0
  105. package/tools/bin/uninstall-project-launchd.sh +55 -0
  106. package/tools/bin/update-github-labels.sh +14 -0
  107. package/tools/bin/worker-status.sh +19 -0
  108. package/tools/bin/workflow-catalog.sh +77 -0
  109. package/tools/dashboard/app.js +286 -0
  110. package/tools/dashboard/dashboard_snapshot.py +466 -0
  111. package/tools/dashboard/index.html +41 -0
  112. package/tools/dashboard/server.py +64 -0
  113. package/tools/dashboard/styles.css +351 -0
  114. package/tools/templates/issue-prompt-template.md +109 -0
  115. package/tools/templates/pr-fix-template.md +120 -0
  116. package/tools/templates/pr-merge-repair-template.md +91 -0
  117. package/tools/templates/pr-review-template.md +62 -0
  118. package/tools/templates/scheduled-issue-prompt-template.md +62 -0
  119. package/tools/tests/test-agent-control-plane-npm-cli.sh +279 -0
  120. package/tools/tests/test-agent-github-update-labels-falls-back-to-repository-id.sh +56 -0
  121. package/tools/tests/test-agent-project-claude-session-wrapper-clears-stale-sandbox-artifacts.sh +89 -0
  122. package/tools/tests/test-agent-project-claude-session-wrapper-does-not-retry-provider-quota.sh +82 -0
  123. package/tools/tests/test-agent-project-claude-session-wrapper-retries-transient-failures.sh +90 -0
  124. package/tools/tests/test-agent-project-claude-session-wrapper-times-out.sh +73 -0
  125. package/tools/tests/test-agent-project-claude-session-wrapper.sh +103 -0
  126. package/tools/tests/test-agent-project-cleanup-session-orphan-fallback.sh +90 -0
  127. package/tools/tests/test-agent-project-cleanup-session-skip-worktree-cleanup.sh +90 -0
  128. package/tools/tests/test-agent-project-codex-live-thread-persist.sh +76 -0
  129. package/tools/tests/test-agent-project-codex-recovery.sh +731 -0
  130. package/tools/tests/test-agent-project-codex-session-wrapper-clears-stale-sandbox-artifacts.sh +105 -0
  131. package/tools/tests/test-agent-project-codex-session-wrapper.sh +97 -0
  132. package/tools/tests/test-agent-project-open-pr-worktree-config-prefix.sh +81 -0
  133. package/tools/tests/test-agent-project-openclaw-session-wrapper-clears-stale-sandbox-artifacts.sh +109 -0
  134. package/tools/tests/test-agent-project-openclaw-session-wrapper-infers-blocked-result-contract.sh +89 -0
  135. package/tools/tests/test-agent-project-openclaw-session-wrapper-recovers-literal-env-artifacts.sh +113 -0
  136. package/tools/tests/test-agent-project-openclaw-session-wrapper-recovers-version-mismatch.sh +135 -0
  137. package/tools/tests/test-agent-project-openclaw-session-wrapper-resident.sh +179 -0
  138. package/tools/tests/test-agent-project-openclaw-session-wrapper-reuses-existing-agent-after-add-race.sh +119 -0
  139. package/tools/tests/test-agent-project-openclaw-session-wrapper-terminates-rate-limit-hang.sh +91 -0
  140. package/tools/tests/test-agent-project-openclaw-session-wrapper.sh +117 -0
  141. package/tools/tests/test-agent-project-publish-issue-pr-prunes-stale-worktree-entry.sh +148 -0
  142. package/tools/tests/test-agent-project-publish-issue-pr-reads-archived-session.sh +146 -0
  143. package/tools/tests/test-agent-project-publish-issue-pr-recovers-final-head.sh +145 -0
  144. package/tools/tests/test-agent-project-publish-issue-pr-reuses-existing-worktree.sh +147 -0
  145. package/tools/tests/test-agent-project-reconcile-failure-reason.sh +456 -0
  146. package/tools/tests/test-agent-project-reconcile-issue-archived-session-fallback.sh +96 -0
  147. package/tools/tests/test-agent-project-reconcile-issue-before-blocked.sh +90 -0
  148. package/tools/tests/test-agent-project-reconcile-issue-host-verification-recovery-uses-recovered-worktree.sh +212 -0
  149. package/tools/tests/test-agent-project-reconcile-issue-host-verification-recovery.sh +207 -0
  150. package/tools/tests/test-agent-project-reconcile-issue-provider-quota-schedules-provider-cooldown.sh +101 -0
  151. package/tools/tests/test-agent-project-reconcile-issue-session-backfills-lane-metadata-from-worker-key.sh +113 -0
  152. package/tools/tests/test-agent-project-reconcile-issue-session-clears-stale-failed-summary.sh +117 -0
  153. package/tools/tests/test-agent-project-reconcile-issue-session-initializes-shared-agent-home.sh +55 -0
  154. package/tools/tests/test-agent-project-reconcile-issue-session-normalizes-runner-state.sh +125 -0
  155. package/tools/tests/test-agent-project-reconcile-issue-session-records-invalid-contract-summary.sh +118 -0
  156. package/tools/tests/test-agent-project-reconcile-issue-session-skips-duplicate-blocked-comment.sh +144 -0
  157. package/tools/tests/test-agent-project-reconcile-issue-session-standardizes-no-commits-blocker.sh +145 -0
  158. package/tools/tests/test-agent-project-reconcile-issue-session-synthesizes-blocked-comment.sh +139 -0
  159. package/tools/tests/test-agent-project-reconcile-pr-blocked-host-recovery.sh +242 -0
  160. package/tools/tests/test-agent-project-reconcile-pr-guard-blocked-no-commit.sh +142 -0
  161. package/tools/tests/test-agent-project-reconcile-pr-provider-quota-schedules-provider-cooldown.sh +106 -0
  162. package/tools/tests/test-agent-project-reconcile-pr-session-initializes-shared-agent-home.sh +66 -0
  163. package/tools/tests/test-agent-project-reconcile-pr-updated-branch-noop.sh +129 -0
  164. package/tools/tests/test-audit-agent-worktrees-active-launch-skips-git-inspection.sh +69 -0
  165. package/tools/tests/test-audit-agent-worktrees-broken-worktree.sh +43 -0
  166. package/tools/tests/test-audit-agent-worktrees-pending-launch-owner.sh +46 -0
  167. package/tools/tests/test-audit-agent-worktrees-unreconciled-owner.sh +79 -0
  168. package/tools/tests/test-audit-issue-routing-managed-branch-globs.sh +56 -0
  169. package/tools/tests/test-branch-verification-guard-generated-artifacts.sh +72 -0
  170. package/tools/tests/test-branch-verification-guard-targeted-coverage.sh +125 -0
  171. package/tools/tests/test-codex-quota-manager-failure-driven-rotation.sh +178 -0
  172. package/tools/tests/test-codex-quota-wrapper.sh +37 -0
  173. package/tools/tests/test-contribution-docs.sh +18 -0
  174. package/tools/tests/test-control-plane-dashboard-runtime-smoke.sh +343 -0
  175. package/tools/tests/test-create-follow-up-issue.sh +73 -0
  176. package/tools/tests/test-dashboard-launchd-bootstrap.sh +55 -0
  177. package/tools/tests/test-flow-export-execution-env-exports-repo-id.sh +30 -0
  178. package/tools/tests/test-flow-export-github-cli-auth-env-prefers-git-credential.sh +48 -0
  179. package/tools/tests/test-flow-github-api-repo-fallback-preserves-input.sh +85 -0
  180. package/tools/tests/test-flow-github-api-repo-prefers-explicit-repository-id.sh +60 -0
  181. package/tools/tests/test-flow-github-issue-list-falls-back-to-repository-id.sh +64 -0
  182. package/tools/tests/test-flow-github-pr-list-falls-back-to-repository-id.sh +77 -0
  183. package/tools/tests/test-flow-resident-can-reuse-does-not-leak-metadata.sh +52 -0
  184. package/tools/tests/test-flow-resident-reap-stale-controllers.sh +63 -0
  185. package/tools/tests/test-flow-resolve-codex-quota-tools.sh +104 -0
  186. package/tools/tests/test-flow-runtime-doctor-profile-selection.sh +27 -0
  187. package/tools/tests/test-heartbeat-codex-pr-linked-issue-exclusion.sh +79 -0
  188. package/tools/tests/test-heartbeat-hooks-enqueue-resident-issue-for-idle-controller.sh +115 -0
  189. package/tools/tests/test-heartbeat-hooks-enqueue-resident-issue-for-live-lane-controller.sh +117 -0
  190. package/tools/tests/test-heartbeat-hooks-start-resident-issue-loop-claude.sh +96 -0
  191. package/tools/tests/test-heartbeat-hooks-start-resident-issue-loop-codex.sh +96 -0
  192. package/tools/tests/test-heartbeat-hooks-start-resident-issue-loop.sh +96 -0
  193. package/tools/tests/test-heartbeat-loop-auth-wait-does-not-consume-capacity.sh +170 -0
  194. package/tools/tests/test-heartbeat-loop-blocked-recovery-lane.sh +201 -0
  195. package/tools/tests/test-heartbeat-loop-blocked-recovery-vs-pr-reservation.sh +201 -0
  196. package/tools/tests/test-heartbeat-loop-idle-resident-controller-does-not-block-launches.sh +160 -0
  197. package/tools/tests/test-heartbeat-loop-pr-launch-dedup.sh +133 -0
  198. package/tools/tests/test-heartbeat-loop-provider-cooldown-suppresses-launches.sh +157 -0
  199. package/tools/tests/test-heartbeat-loop-reaps-stale-resident-controller.sh +181 -0
  200. package/tools/tests/test-heartbeat-loop-waiting-provider-resident-controller-does-not-block-launches.sh +160 -0
  201. package/tools/tests/test-heartbeat-ready-issues-blocked-recovery.sh +134 -0
  202. package/tools/tests/test-heartbeat-safe-auto-dynamic-concurrency.sh +162 -0
  203. package/tools/tests/test-heartbeat-safe-auto-no-tmux-sessions.sh +136 -0
  204. package/tools/tests/test-heartbeat-safe-auto-openclaw-skips-codex-quota.sh +139 -0
  205. package/tools/tests/test-heartbeat-safe-auto-quota-health-signal.sh +119 -0
  206. package/tools/tests/test-heartbeat-safe-auto-stale-shared-loop-pid-does-not-skip.sh +140 -0
  207. package/tools/tests/test-heartbeat-safe-auto-static-capacity-without-quota-cache.sh +142 -0
  208. package/tools/tests/test-heartbeat-safe-auto-zero-healthy-pools.sh +141 -0
  209. package/tools/tests/test-heartbeat-sync-issue-labels-empty-schedule.sh +65 -0
  210. package/tools/tests/test-heartbeat-sync-open-agent-prs-terminal-clears-running.sh +179 -0
  211. package/tools/tests/test-install-dashboard-launchd.sh +78 -0
  212. package/tools/tests/test-install-project-launchd-adds-tool-paths.sh +87 -0
  213. package/tools/tests/test-install-project-launchd.sh +110 -0
  214. package/tools/tests/test-issue-local-workspace-install-policy.sh +81 -0
  215. package/tools/tests/test-issue-publish-scope-guard-docs-signal.sh +70 -0
  216. package/tools/tests/test-issue-reconcile-hooks-success-clears-blocked.sh +36 -0
  217. package/tools/tests/test-kick-scheduler-requires-explicit-profile.sh +47 -0
  218. package/tools/tests/test-label-follow-up-issues-falls-back-to-repository-id.sh +132 -0
  219. package/tools/tests/test-manual-operator-entrypoints-require-explicit-profile.sh +64 -0
  220. package/tools/tests/test-package-funding-metadata.sh +21 -0
  221. package/tools/tests/test-package-public-metadata.sh +62 -0
  222. package/tools/tests/test-placeholder-worker-adapters.sh +38 -0
  223. package/tools/tests/test-pr-reconcile-hooks-refreshes-recurring-issue-checklist.sh +110 -0
  224. package/tools/tests/test-pr-risk-cohesive-mobile-locale-scope.sh +70 -0
  225. package/tools/tests/test-pr-risk-fix-label-semantics.sh +114 -0
  226. package/tools/tests/test-pr-risk-local-first-no-checks.sh +70 -0
  227. package/tools/tests/test-prepare-worktree-simple-repo-baseline.sh +67 -0
  228. package/tools/tests/test-profile-activate.sh +33 -0
  229. package/tools/tests/test-profile-adopt-allow-missing-repo.sh +68 -0
  230. package/tools/tests/test-profile-adopt-skip-workspace-sync-missing-file.sh +61 -0
  231. package/tools/tests/test-profile-adopt-syncs-anchor-and-workspace.sh +90 -0
  232. package/tools/tests/test-profile-smoke-collision.sh +44 -0
  233. package/tools/tests/test-profile-smoke-invalid-claude-config.sh +31 -0
  234. package/tools/tests/test-profile-smoke-invalid-provider-pool.sh +68 -0
  235. package/tools/tests/test-profile-smoke-repo-slug-mismatch.sh +36 -0
  236. package/tools/tests/test-profile-smoke.sh +45 -0
  237. package/tools/tests/test-project-init-force-and-skip-sync.sh +61 -0
  238. package/tools/tests/test-project-init-repo-slug-mismatch.sh +29 -0
  239. package/tools/tests/test-project-init.sh +66 -0
  240. package/tools/tests/test-project-launchd-bootstrap.sh +66 -0
  241. package/tools/tests/test-project-remove.sh +150 -0
  242. package/tools/tests/test-project-runtime-supervisor.sh +47 -0
  243. package/tools/tests/test-project-runtimectl-launchd.sh +115 -0
  244. package/tools/tests/test-project-runtimectl-missing-profile.sh +54 -0
  245. package/tools/tests/test-project-runtimectl-start-falls-back-to-bootstrap.sh +108 -0
  246. package/tools/tests/test-project-runtimectl-status-reports-supervisor-as-heartbeat-parent.sh +95 -0
  247. package/tools/tests/test-project-runtimectl-status-supervisor-running.sh +59 -0
  248. package/tools/tests/test-project-runtimectl-stop-cancels-pending-kick.sh +85 -0
  249. package/tools/tests/test-project-runtimectl-stop-clears-running-labels.sh +78 -0
  250. package/tools/tests/test-project-runtimectl.sh +212 -0
  251. package/tools/tests/test-provider-cooldown-state-prefers-runtime-worker-context.sh +39 -0
  252. package/tools/tests/test-provider-cooldown-state.sh +59 -0
  253. package/tools/tests/test-public-repo-docs.sh +159 -0
  254. package/tools/tests/test-reconcile-pr-worker-acp-config-routing.sh +75 -0
  255. package/tools/tests/test-render-dashboard-snapshot.sh +149 -0
  256. package/tools/tests/test-render-flow-config-demo-profile.sh +36 -0
  257. package/tools/tests/test-render-flow-config-provider-pool-fallback.sh +81 -0
  258. package/tools/tests/test-render-flow-config.sh +52 -0
  259. package/tools/tests/test-run-codex-task-claude-routing.sh +125 -0
  260. package/tools/tests/test-run-codex-task-codex-resident-routing.sh +108 -0
  261. package/tools/tests/test-run-codex-task-kilo-routing.sh +98 -0
  262. package/tools/tests/test-run-codex-task-openclaw-resident-routing.sh +117 -0
  263. package/tools/tests/test-run-codex-task-openclaw-routing.sh +113 -0
  264. package/tools/tests/test-run-codex-task-opencode-routing.sh +98 -0
  265. package/tools/tests/test-run-codex-task-provider-pool-fallback-routing.sh +146 -0
  266. package/tools/tests/test-scaffold-profile.sh +108 -0
  267. package/tools/tests/test-serve-dashboard.sh +93 -0
  268. package/tools/tests/test-start-issue-worker-blocked-context.sh +129 -0
  269. package/tools/tests/test-start-issue-worker-blocks-complete-recurring-checklist.sh +189 -0
  270. package/tools/tests/test-start-issue-worker-local-install-routing.sh +157 -0
  271. package/tools/tests/test-start-issue-worker-profile-template-routing.sh +149 -0
  272. package/tools/tests/test-start-issue-worker-recurring-resident-reuse-codex.sh +212 -0
  273. package/tools/tests/test-start-issue-worker-recurring-resident-reuse.sh +219 -0
  274. package/tools/tests/test-start-issue-worker-renders-verification-snippet.sh +155 -0
  275. package/tools/tests/test-start-issue-worker-resident-reuse-falls-back-to-new-worktree.sh +199 -0
  276. package/tools/tests/test-start-pr-fix-worker-host-blocker-context.sh +275 -0
  277. package/tools/tests/test-start-resident-issue-loop-adopts-next-recurring-issue.sh +185 -0
  278. package/tools/tests/test-start-resident-issue-loop-clears-pending-while-waiting-due.sh +152 -0
  279. package/tools/tests/test-start-resident-issue-loop-consumes-queued-lease.sh +186 -0
  280. package/tools/tests/test-start-resident-issue-loop-fails-over-provider-pool.sh +212 -0
  281. package/tools/tests/test-start-resident-issue-loop-immediate-cycles.sh +148 -0
  282. package/tools/tests/test-start-resident-issue-loop-waits-for-provider.sh +194 -0
  283. package/tools/tests/test-start-resident-issue-loop-waits-for-terminal-reconcile-status.sh +198 -0
  284. package/tools/tests/test-start-resident-issue-loop-yields-to-live-lane-controller.sh +145 -0
  285. package/tools/tests/test-sync-pr-labels-fix-lane-uses-repair-queued.sh +67 -0
  286. package/tools/tests/test-sync-recurring-issue-checklist-backfills-workflow-complete-blocker.sh +70 -0
  287. package/tools/tests/test-sync-recurring-issue-checklist.sh +95 -0
  288. package/tools/tests/test-sync-shared-agent-home-local-source-root.sh +66 -0
  289. package/tools/tests/test-sync-shared-agent-home-preserves-unrelated-workflow-catalog-skill.sh +47 -0
  290. package/tools/tests/test-test-smoke.sh +86 -0
  291. package/tools/tests/test-uninstall-project-launchd.sh +37 -0
  292. package/tools/tests/test-update-github-labels-prefers-sibling-helper.sh +49 -0
  293. package/tools/tests/test-workflow-catalog.sh +43 -0
  294. package/tools/vendor/codex-quota/LICENSE +21 -0
  295. package/tools/vendor/codex-quota/README.md +459 -0
  296. package/tools/vendor/codex-quota/codex-quota.js +261 -0
  297. package/tools/vendor/codex-quota/lib/claude-accounts.js +226 -0
  298. package/tools/vendor/codex-quota/lib/claude-oauth.js +174 -0
  299. package/tools/vendor/codex-quota/lib/claude-tokens.js +471 -0
  300. package/tools/vendor/codex-quota/lib/claude-usage.js +929 -0
  301. package/tools/vendor/codex-quota/lib/codex-accounts.js +205 -0
  302. package/tools/vendor/codex-quota/lib/codex-tokens.js +326 -0
  303. package/tools/vendor/codex-quota/lib/codex-usage.js +32 -0
  304. package/tools/vendor/codex-quota/lib/color.js +72 -0
  305. package/tools/vendor/codex-quota/lib/constants.js +57 -0
  306. package/tools/vendor/codex-quota/lib/container.js +143 -0
  307. package/tools/vendor/codex-quota/lib/display.js +1111 -0
  308. package/tools/vendor/codex-quota/lib/fs.js +63 -0
  309. package/tools/vendor/codex-quota/lib/handlers.js +2060 -0
  310. package/tools/vendor/codex-quota/lib/jwt.js +33 -0
  311. package/tools/vendor/codex-quota/lib/oauth.js +486 -0
  312. package/tools/vendor/codex-quota/lib/paths.js +34 -0
  313. package/tools/vendor/codex-quota/lib/prompts.js +44 -0
  314. package/tools/vendor/codex-quota/lib/sync.js +1438 -0
  315. package/tools/vendor/codex-quota/lib/token-match.js +96 -0
  316. package/tools/vendor/codex-quota-manager/scripts/auto-switch.sh +500 -0
  317. package/tools/vendor/codex-quota-manager/scripts/batch-add.sh +123 -0
@@ -0,0 +1,2060 @@
1
+ /**
2
+ * Subcommand handlers (add, switch, sync, list, remove, quota, etc.)
3
+ * Depends on: most other modules
4
+ */
5
+
6
+ import { existsSync, readFileSync, mkdirSync, unlinkSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import {
9
+ MULTI_ACCOUNT_PATHS,
10
+ CLAUDE_CREDENTIALS_PATH,
11
+ CLAUDE_MULTI_ACCOUNT_PATHS,
12
+ PRIMARY_CMD,
13
+ } from "./constants.js";
14
+ import { GREEN, RED, YELLOW, colorize } from "./color.js";
15
+ import { getPackageVersion } from "./color.js";
16
+ import {
17
+ shortenPath,
18
+ drawBox,
19
+ buildAccountUsageLines,
20
+ buildClaudeUsageLines,
21
+ formatExpiryStatus,
22
+ printHelp,
23
+ printHelpCodex,
24
+ printHelpClaude,
25
+ printHelpAdd,
26
+ printHelpCodexReauth,
27
+ printHelpSwitch,
28
+ printHelpCodexSync,
29
+ printHelpList,
30
+ printHelpRemove,
31
+ printHelpQuota,
32
+ printHelpClaudeAdd,
33
+ printHelpClaudeReauth,
34
+ printHelpClaudeSwitch,
35
+ printHelpClaudeSync,
36
+ printHelpClaudeList,
37
+ printHelpClaudeRemove,
38
+ printHelpClaudeQuota,
39
+ } from "./display.js";
40
+ import {
41
+ generatePKCE,
42
+ generateState,
43
+ buildAuthUrl,
44
+ checkPortAvailable,
45
+ openBrowser,
46
+ startCallbackServer,
47
+ exchangeCodeForTokens,
48
+ } from "./oauth.js";
49
+ import {
50
+ buildClaudeAuthUrl,
51
+ parseClaudeCodeState,
52
+ exchangeClaudeCodeForTokens,
53
+ handleClaudeOAuthFlow,
54
+ } from "./claude-oauth.js";
55
+ import {
56
+ loadAccountsFromEnv,
57
+ loadAccountsFromFile,
58
+ loadAccountFromCodexCli,
59
+ loadAllAccounts,
60
+ loadAllAccountsNoDedup,
61
+ findAccountByLabel,
62
+ getAllLabels,
63
+ isValidAccount,
64
+ readCodexActiveStoreContainer,
65
+ getCodexActiveLabelInfo,
66
+ } from "./codex-accounts.js";
67
+ import {
68
+ loadClaudeAccounts,
69
+ loadClaudeAccountsFromFile,
70
+ findClaudeAccountByLabel,
71
+ getClaudeLabels,
72
+ getClaudeActiveLabelInfo,
73
+ readClaudeActiveStoreContainer,
74
+ findClaudeSessionKey,
75
+ } from "./claude-accounts.js";
76
+ import {
77
+ updateOpencodeAuth,
78
+ updatePiAuth,
79
+ persistOpenAiOAuthTokens,
80
+ ensureFreshToken,
81
+ } from "./codex-tokens.js";
82
+ import {
83
+ updateClaudeCredentials,
84
+ updateOpencodeClaudeAuth,
85
+ updatePiClaudeAuth,
86
+ persistClaudeOAuthTokens,
87
+ ensureFreshClaudeOAuthToken,
88
+ } from "./claude-tokens.js";
89
+ import { fetchUsage } from "./codex-usage.js";
90
+ import {
91
+ loadClaudeOAuthFromClaudeCode,
92
+ loadClaudeOAuthFromOpenCode,
93
+ loadClaudeOAuthFromEnv,
94
+ loadAllClaudeOAuthAccounts,
95
+ fetchClaudeOAuthUsage,
96
+ fetchClaudeOAuthUsageForAccount,
97
+ fetchClaudeUsage,
98
+ deduplicateClaudeOAuthAccounts,
99
+ deduplicateClaudeResultsByUsage,
100
+ } from "./claude-usage.js";
101
+ import { readMultiAccountContainer, writeMultiAccountContainer, mapContainerAccounts } from "./container.js";
102
+ import { writeFileAtomic } from "./fs.js";
103
+ import { getOpencodeAuthPath, getCodexCliAuthPath, getPiAuthPath } from "./paths.js";
104
+ import { extractAccountId, extractProfile } from "./jwt.js";
105
+ import { promptConfirm, promptInput } from "./prompts.js";
106
+ import {
107
+ detectCodexDivergence,
108
+ detectClaudeDivergence,
109
+ setCodexActiveLabel,
110
+ setClaudeActiveLabel,
111
+ getActiveAccountId,
112
+ getActiveAccountInfo,
113
+ findFresherOpenAiOAuthStore,
114
+ findFresherClaudeOAuthStore,
115
+ findClaudeOAuthRecoveryStore,
116
+ findCodexAccountByLabelInFiles,
117
+ clearCodexQuotaLabelForRemovedAccount,
118
+ maybeImportClaudeOauthStores,
119
+ getActiveClaudeAccountFromStore,
120
+ handleCodexSync,
121
+ handleClaudeSync,
122
+ } from "./sync.js";
123
+
124
+ // Handlers extracted from codex-quota.js
125
+ export async function handleAdd(args, flags) {
126
+ // Extract optional label from args (can be overridden after auth)
127
+ let label = args[0] || null;
128
+
129
+ try {
130
+ // 1. Check if port is available before starting
131
+ const portAvailable = await checkPortAvailable(1455);
132
+ if (!portAvailable) {
133
+ throw new Error(`Port 1455 is in use. Close other ${PRIMARY_CMD} instances and retry.`);
134
+ }
135
+
136
+ // 2. Generate PKCE code verifier and challenge
137
+ const { verifier, challenge } = generatePKCE();
138
+
139
+ // 3. Generate random state for CSRF protection
140
+ const state = generateState();
141
+
142
+ // 4. Build authorization URL
143
+ const authUrl = buildAuthUrl(challenge, state);
144
+
145
+ // 5. Print starting message
146
+ console.log("Starting OAuth authentication...");
147
+
148
+ // 6. Start callback server (in background)
149
+ const callbackPromise = startCallbackServer(state);
150
+
151
+ // 7. Open browser or print URL
152
+ openBrowser(authUrl, { noBrowser: flags.noBrowser });
153
+
154
+ // 8. Wait for callback with auth code
155
+ console.log("Waiting for browser authentication...");
156
+ const { code, state: returnedState } = await callbackPromise;
157
+
158
+ // 9. Verify state matches (already done in startCallbackServer, but double-check)
159
+ if (returnedState !== state) {
160
+ throw new Error("State mismatch. Possible CSRF attack.");
161
+ }
162
+
163
+ // 10. Exchange code for tokens
164
+ console.log("Exchanging code for tokens...");
165
+ const tokens = await exchangeCodeForTokens(code, verifier);
166
+
167
+ // 11. Derive label from email if not provided
168
+ if (!label && tokens.email) {
169
+ // Use email prefix as suggested label (e.g., "john" from "john@example.com")
170
+ label = tokens.email.split("@")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "");
171
+ }
172
+ if (!label) {
173
+ // Fallback to generic label with timestamp
174
+ label = `account-${Date.now()}`;
175
+ }
176
+
177
+ // 12. Check for duplicate labels
178
+ const existingLabels = getAllLabels();
179
+ if (existingLabels.includes(label)) {
180
+ throw new Error(`Label "${label}" already exists. Use a different label or remove the existing one.\nExisting labels: ${existingLabels.join(", ")}`);
181
+ }
182
+
183
+ // 13. Validate label format (alphanumeric with hyphens/underscores)
184
+ if (!/^[a-zA-Z0-9_-]+$/.test(label)) {
185
+ throw new Error(`Invalid label "${label}". Use only letters, numbers, hyphens, and underscores.`);
186
+ }
187
+
188
+ // 14. Create new account object
189
+ const newAccount = {
190
+ label: label,
191
+ accountId: tokens.accountId,
192
+ access: tokens.accessToken,
193
+ refresh: tokens.refreshToken,
194
+ idToken: tokens.idToken,
195
+ expires: tokens.expires,
196
+ };
197
+
198
+ // 15. Determine target file and save
199
+ const targetPath = MULTI_ACCOUNT_PATHS[0]; // ~/.codex-accounts.json
200
+ const container = readMultiAccountContainer(targetPath);
201
+ const accounts = [...container.accounts, newAccount];
202
+ writeMultiAccountContainer(targetPath, container, accounts, {}, { mode: 0o600 });
203
+
204
+ // 16. Print success message (human-readable OR JSON, not both)
205
+ if (flags.json) {
206
+ console.log(JSON.stringify({
207
+ success: true,
208
+ label: label,
209
+ email: tokens.email,
210
+ accountId: tokens.accountId,
211
+ source: targetPath,
212
+ }, null, 2));
213
+ } else {
214
+ const emailDisplay = tokens.email ? ` <${tokens.email}>` : "";
215
+ const lines = [
216
+ colorize(`Added account ${label}${emailDisplay}`, GREEN),
217
+ "",
218
+ `Saved to: ${shortenPath(targetPath)}`,
219
+ "",
220
+ `Run 'cq codex switch ${label}' to activate this account`,
221
+ ];
222
+ const boxLines = drawBox(lines);
223
+ console.log(boxLines.join("\n"));
224
+ }
225
+ } catch (error) {
226
+ // Handle specific error types with user-friendly messages (JSON OR human-readable, not both)
227
+ if (flags.json) {
228
+ console.log(JSON.stringify({
229
+ success: false,
230
+ error: error.message,
231
+ }, null, 2));
232
+ } else if (error.message.includes("Port 1455")) {
233
+ console.error(colorize(`Error: ${error.message}`, RED));
234
+ } else if (error.message.includes("timed out")) {
235
+ console.error(colorize(`Error: ${error.message}`, RED));
236
+ } else if (error.message.includes("cancelled")) {
237
+ console.error(colorize(`Error: ${error.message}`, RED));
238
+ } else if (error.message.includes("State mismatch")) {
239
+ console.error(colorize("Error: State mismatch. Possible CSRF attack.", RED));
240
+ } else if (error.message.includes("Token exchange failed")) {
241
+ console.error(colorize(`Error: ${error.message}`, RED));
242
+ } else if (error.message.includes("OAuth error")) {
243
+ console.error(colorize(`Error: Authentication was denied or cancelled.`, RED));
244
+ } else {
245
+ console.error(colorize(`Error: ${error.message}`, RED));
246
+ }
247
+
248
+ process.exit(1);
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Handle reauth subcommand - re-authenticate an existing Codex account via OAuth browser flow
254
+ * This updates the existing account's tokens without changing the label
255
+ * @param {string[]} args - Non-flag arguments (label is required)
256
+ * @param {{ json: boolean, noBrowser: boolean }} flags - Parsed flags
257
+ */
258
+ export async function handleCodexReauth(args, flags) {
259
+ const label = args[0];
260
+ if (!label) {
261
+ if (flags.json) {
262
+ console.log(JSON.stringify({ success: false, error: "Missing required label argument" }, null, 2));
263
+ } else {
264
+ console.error(colorize(`Usage: ${PRIMARY_CMD} codex reauth <label>`, RED));
265
+ console.error("Re-authenticates an existing account via OAuth browser flow.");
266
+ }
267
+ process.exit(1);
268
+ }
269
+
270
+ try {
271
+ // 1. Find existing account by label
272
+ const existingAccount = findAccountByLabel(label);
273
+ if (!existingAccount) {
274
+ const allLabels = getAllLabels();
275
+ if (flags.json) {
276
+ console.log(JSON.stringify({
277
+ success: false,
278
+ error: `Account "${label}" not found`,
279
+ availableLabels: allLabels,
280
+ }, null, 2));
281
+ } else if (allLabels.length === 0) {
282
+ console.error(colorize(`Account "${label}" not found. No accounts configured.`, RED));
283
+ console.error(`Run '${PRIMARY_CMD} codex add' to add an account.`);
284
+ } else {
285
+ console.error(colorize(`Account "${label}" not found.`, RED));
286
+ console.error(`Available: ${allLabels.join(", ")}`);
287
+ }
288
+ process.exit(1);
289
+ }
290
+
291
+ const source = existingAccount.source;
292
+
293
+ // 2. Check if account can be re-authenticated (must be in a multi-account file)
294
+ if (source === "env") {
295
+ if (flags.json) {
296
+ console.log(JSON.stringify({
297
+ success: false,
298
+ error: "Cannot re-authenticate account from CODEX_ACCOUNTS env var. Modify the env var directly.",
299
+ }, null, 2));
300
+ } else {
301
+ console.error(colorize("Cannot re-authenticate account from CODEX_ACCOUNTS env var.", RED));
302
+ console.error("Modify the env var directly to update this account.");
303
+ }
304
+ process.exit(1);
305
+ }
306
+
307
+ // 3. Check if port is available before starting
308
+ const portAvailable = await checkPortAvailable(1455);
309
+ if (!portAvailable) {
310
+ throw new Error(`Port 1455 is in use. Close other ${PRIMARY_CMD} instances and retry.`);
311
+ }
312
+
313
+ // 4. Generate PKCE code verifier and challenge
314
+ const { verifier, challenge } = generatePKCE();
315
+
316
+ // 5. Generate random state for CSRF protection
317
+ const state = generateState();
318
+
319
+ // 6. Build authorization URL
320
+ const authUrl = buildAuthUrl(challenge, state);
321
+
322
+ // 7. Print starting message
323
+ console.log(`Re-authenticating account "${label}"...`);
324
+
325
+ // 8. Start callback server (in background)
326
+ const callbackPromise = startCallbackServer(state);
327
+
328
+ // 9. Open browser or print URL
329
+ openBrowser(authUrl, { noBrowser: flags.noBrowser });
330
+
331
+ // 10. Wait for callback with auth code
332
+ console.log("Waiting for browser authentication...");
333
+ const { code, state: returnedState } = await callbackPromise;
334
+
335
+ // 11. Verify state matches
336
+ if (returnedState !== state) {
337
+ throw new Error("State mismatch. Possible CSRF attack.");
338
+ }
339
+
340
+ // 12. Exchange code for tokens
341
+ console.log("Exchanging code for tokens...");
342
+ const tokens = await exchangeCodeForTokens(code, verifier);
343
+
344
+ // 13. Update the account entry in the source file
345
+ const container = readMultiAccountContainer(source);
346
+ if (container.rootType === "invalid") {
347
+ throw new Error(`Failed to parse ${source}`);
348
+ }
349
+
350
+ const updatedAccounts = container.accounts.map(entry => {
351
+ if (!entry || typeof entry !== "object" || entry.label !== label) {
352
+ return entry;
353
+ }
354
+ // Preserve any extra fields from the existing entry
355
+ return {
356
+ ...entry,
357
+ accountId: tokens.accountId,
358
+ access: tokens.accessToken,
359
+ refresh: tokens.refreshToken,
360
+ idToken: tokens.idToken,
361
+ expires: tokens.expires,
362
+ };
363
+ });
364
+
365
+ writeMultiAccountContainer(source, container, updatedAccounts, {}, { mode: 0o600 });
366
+
367
+ // 14. Update CLI auth files if this account is active
368
+ const activeInfo = getCodexActiveLabelInfo();
369
+ if (activeInfo.activeLabel === label) {
370
+ // This is the active account - sync to CLI auth files
371
+ const updatedAccount = {
372
+ label,
373
+ accountId: tokens.accountId,
374
+ access: tokens.accessToken,
375
+ refresh: tokens.refreshToken,
376
+ idToken: tokens.idToken,
377
+ expires: tokens.expires,
378
+ };
379
+
380
+ // Update Codex CLI auth.json
381
+ const codexAuthPath = getCodexCliAuthPath();
382
+ let existingAuth = {};
383
+ if (existsSync(codexAuthPath)) {
384
+ try {
385
+ const raw = readFileSync(codexAuthPath, "utf-8");
386
+ existingAuth = JSON.parse(raw);
387
+ } catch {
388
+ existingAuth = {};
389
+ }
390
+ }
391
+
392
+ const codexTokens = {
393
+ access_token: tokens.accessToken,
394
+ refresh_token: tokens.refreshToken,
395
+ account_id: tokens.accountId,
396
+ expires_at: Math.floor(tokens.expires / 1000),
397
+ };
398
+ if (tokens.idToken) {
399
+ codexTokens.id_token = tokens.idToken;
400
+ }
401
+
402
+ const newAuth = {
403
+ ...(existingAuth.OPENAI_API_KEY !== undefined ? { OPENAI_API_KEY: existingAuth.OPENAI_API_KEY } : {}),
404
+ tokens: codexTokens,
405
+ last_refresh: new Date().toISOString(),
406
+ codex_quota_label: label,
407
+ };
408
+
409
+ const codexDir = dirname(codexAuthPath);
410
+ if (!existsSync(codexDir)) {
411
+ mkdirSync(codexDir, { recursive: true });
412
+ }
413
+ writeFileAtomic(codexAuthPath, JSON.stringify(newAuth, null, 2) + "\n", { mode: 0o600 });
414
+
415
+ // Update OpenCode and pi auth files
416
+ updateOpencodeAuth(updatedAccount);
417
+ updatePiAuth(updatedAccount);
418
+ }
419
+
420
+ // 15. Print success message
421
+ if (flags.json) {
422
+ console.log(JSON.stringify({
423
+ success: true,
424
+ label,
425
+ email: tokens.email,
426
+ accountId: tokens.accountId,
427
+ source,
428
+ }, null, 2));
429
+ } else {
430
+ const emailDisplay = tokens.email ? ` <${tokens.email}>` : "";
431
+ const lines = [
432
+ colorize(`Re-authenticated account ${label}${emailDisplay}`, GREEN),
433
+ "",
434
+ `Updated: ${shortenPath(source)}`,
435
+ ];
436
+ if (activeInfo.activeLabel === label) {
437
+ lines.push("");
438
+ lines.push("CLI auth files also updated (active account)");
439
+ }
440
+ const boxLines = drawBox(lines);
441
+ console.log(boxLines.join("\n"));
442
+ }
443
+ } catch (error) {
444
+ if (flags.json) {
445
+ console.log(JSON.stringify({
446
+ success: false,
447
+ error: error.message,
448
+ }, null, 2));
449
+ } else {
450
+ console.error(colorize(`Error: ${error.message}`, RED));
451
+ }
452
+ process.exit(1);
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Handle switch subcommand - switch active account for Codex CLI/OpenCode/pi auth files
458
+ * @param {string[]} args - Non-flag arguments (label is required)
459
+ * @param {{ json: boolean }} flags - Parsed flags
460
+ */
461
+ export async function handleSwitch(args, flags) {
462
+ // 1. Extract required label
463
+ const label = args[0];
464
+ if (!label) {
465
+ if (flags.json) {
466
+ console.log(JSON.stringify({ success: false, error: "Missing required label argument" }, null, 2));
467
+ } else {
468
+ console.error(colorize(`Usage: ${PRIMARY_CMD} codex switch <label>`, RED));
469
+ console.error("Switches the active account in ~/.codex/auth.json");
470
+ }
471
+ process.exit(1);
472
+ }
473
+
474
+ try {
475
+ // 2. Find account by label from all sources
476
+ const account = findAccountByLabel(label);
477
+ if (!account) {
478
+ const allLabels = getAllLabels();
479
+ if (flags.json) {
480
+ console.log(JSON.stringify({
481
+ success: false,
482
+ error: `Account "${label}" not found`,
483
+ availableLabels: allLabels,
484
+ }, null, 2));
485
+ } else if (allLabels.length === 0) {
486
+ console.error(colorize(`Account "${label}" not found. No accounts configured.`, RED));
487
+ console.error(`Run '${PRIMARY_CMD} codex add' to add an account via OAuth.`);
488
+ } else {
489
+ console.error(colorize(`Account "${label}" not found.`, RED));
490
+ console.error(`Available: ${allLabels.join(", ")}`);
491
+ }
492
+ process.exit(1);
493
+ }
494
+
495
+ // 3. Refresh token if needed (create a temporary array for ensureFreshToken)
496
+ const accountsForRefresh = [account];
497
+ const tokenOk = await ensureFreshToken(account, accountsForRefresh);
498
+ if (!tokenOk) {
499
+ if (flags.json) {
500
+ console.log(JSON.stringify({
501
+ success: false,
502
+ error: `Failed to refresh token for "${label}". Re-authentication may be required.`,
503
+ }, null, 2));
504
+ } else {
505
+ console.error(colorize(`Error: Failed to refresh token for "${label}". Re-authentication may be required.`, RED));
506
+ console.error(`Run '${PRIMARY_CMD} codex add' to re-authenticate this account.`);
507
+ }
508
+ process.exit(1);
509
+ }
510
+
511
+ // 4. Update activeLabel in the source-of-truth multi-account file
512
+ // Always set activeLabel regardless of account source - the label tracking
513
+ // should work even for accounts loaded from env or single-account files
514
+ let activeLabelPath = null;
515
+ let activeLabelError = null;
516
+ try {
517
+ const activeUpdate = setCodexActiveLabel(label);
518
+ activeLabelPath = activeUpdate.path;
519
+ } catch (err) {
520
+ activeLabelError = err?.message ?? String(err);
521
+ }
522
+
523
+ // 5. Read existing ~/.codex/auth.json to preserve OPENAI_API_KEY
524
+ let existingAuth = {};
525
+ const codexAuthPath = getCodexCliAuthPath();
526
+ if (existsSync(codexAuthPath)) {
527
+ try {
528
+ const raw = readFileSync(codexAuthPath, "utf-8");
529
+ existingAuth = JSON.parse(raw);
530
+ } catch {
531
+ // If corrupted, start fresh
532
+ existingAuth = {};
533
+ }
534
+ }
535
+
536
+ // 6. Build new auth.json structure (matching Codex CLI format)
537
+ const tokens = {
538
+ access_token: account.access,
539
+ refresh_token: account.refresh,
540
+ account_id: account.accountId,
541
+ expires_at: Math.floor(account.expires / 1000), // Convert ms to seconds
542
+ };
543
+
544
+ // Only include id_token if it exists (Codex CLI rejects null)
545
+ if (account.idToken) {
546
+ tokens.id_token = account.idToken;
547
+ }
548
+
549
+ const newAuth = {
550
+ // Preserve existing OPENAI_API_KEY if present
551
+ ...(existingAuth.OPENAI_API_KEY !== undefined ? { OPENAI_API_KEY: existingAuth.OPENAI_API_KEY } : {}),
552
+ tokens,
553
+ last_refresh: new Date().toISOString(),
554
+ // Track which managed account we switched to (for detecting native login divergence)
555
+ codex_quota_label: label,
556
+ };
557
+
558
+ // 7. Create ~/.codex directory if needed
559
+ const codexDir = dirname(codexAuthPath);
560
+ if (!existsSync(codexDir)) {
561
+ mkdirSync(codexDir, { recursive: true });
562
+ }
563
+
564
+ // 8. Write auth.json atomically (temp file + rename) with 0600 permissions
565
+ writeFileAtomic(codexAuthPath, JSON.stringify(newAuth, null, 2) + "\n", { mode: 0o600 });
566
+
567
+ // 9. Update OpenCode auth.json if present
568
+ const opencodeUpdate = updateOpencodeAuth(account);
569
+ if (opencodeUpdate.error && !flags.json) {
570
+ console.error(colorize(`Warning: ${opencodeUpdate.error}`, YELLOW));
571
+ }
572
+
573
+ // 10. Update pi auth.json if present
574
+ const piUpdate = updatePiAuth(account);
575
+ if (piUpdate.error && !flags.json) {
576
+ console.error(colorize(`Warning: ${piUpdate.error}`, YELLOW));
577
+ }
578
+
579
+ // 11. Get profile info for display
580
+ const profile = extractProfile(account.access);
581
+
582
+ // 12. Print confirmation (JSON OR human-readable, not both)
583
+ if (flags.json) {
584
+ const output = {
585
+ success: true,
586
+ label: label,
587
+ email: profile.email,
588
+ accountId: account.accountId,
589
+ authPath: codexAuthPath,
590
+ };
591
+ if (activeLabelPath) {
592
+ output.activeLabelPath = activeLabelPath;
593
+ }
594
+ if (activeLabelError) {
595
+ output.activeLabelError = activeLabelError;
596
+ }
597
+ if (opencodeUpdate.updated) {
598
+ output.opencodeAuthPath = opencodeUpdate.path;
599
+ } else if (opencodeUpdate.error) {
600
+ output.opencodeAuthError = opencodeUpdate.error;
601
+ }
602
+ if (piUpdate.updated) {
603
+ output.piAuthPath = piUpdate.path;
604
+ } else if (piUpdate.error) {
605
+ output.piAuthError = piUpdate.error;
606
+ }
607
+ console.log(JSON.stringify(output, null, 2));
608
+ } else {
609
+ if (activeLabelError) {
610
+ console.error(colorize(`Warning: Failed to update activeLabel: ${activeLabelError}`, YELLOW));
611
+ }
612
+ const emailDisplay = profile.email ? ` <${profile.email}>` : "";
613
+ const planDisplay = profile.planType ? ` (${profile.planType})` : "";
614
+ const lines = [
615
+ colorize(`Switched to ${label}${emailDisplay}${planDisplay}`, GREEN),
616
+ "",
617
+ `Codex CLI: ${shortenPath(codexAuthPath)}`,
618
+ ];
619
+ if (activeLabelPath) {
620
+ lines.push(`Active label: ${shortenPath(activeLabelPath)}`);
621
+ }
622
+ if (opencodeUpdate.updated) {
623
+ lines.push(`OpenCode: ${shortenPath(opencodeUpdate.path)}`);
624
+ }
625
+ if (piUpdate.updated) {
626
+ lines.push(`pi: ${shortenPath(piUpdate.path)}`);
627
+ }
628
+ const boxLines = drawBox(lines);
629
+ console.log(boxLines.join("\n"));
630
+ }
631
+ } catch (error) {
632
+ if (flags.json) {
633
+ console.log(JSON.stringify({
634
+ success: false,
635
+ error: error.message,
636
+ }, null, 2));
637
+ } else {
638
+ console.error(colorize(`Error: ${error.message}`, RED));
639
+ }
640
+
641
+ process.exit(1);
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Handle sync subcommand - bi-directional sync for activeLabel account
647
+ * 1. Pull: if a CLI store has the same refresh token but newer access/expires, pull it back
648
+ * 2. Push: write the (now freshest) account tokens to all CLI auth files
649
+ * @param {string[]} args - Non-flag arguments (unused)
650
+ * @param {{ json: boolean, dryRun?: boolean }} flags - Parsed flags
651
+ */
652
+ export async function handleList(flags) {
653
+ const codexDivergence = flags.local ? null : detectCodexDivergence({ allowMigration: false });
654
+ const activeLabel = codexDivergence?.activeLabel ?? null;
655
+ const accounts = loadAllAccounts(activeLabel, { local: flags.local });
656
+
657
+ // Handle zero accounts case
658
+ if (!accounts.length) {
659
+ if (flags.json) {
660
+ console.log(JSON.stringify({ accounts: [] }, null, 2));
661
+ return;
662
+ }
663
+ console.log("No accounts found.");
664
+ console.log("\nSearched:");
665
+ console.log(" - CODEX_ACCOUNTS env var");
666
+ for (const p of MULTI_ACCOUNT_PATHS) {
667
+ console.log(` - ${p}`);
668
+ }
669
+ console.log(` - ${getCodexCliAuthPath()}`);
670
+ console.log(`\nRun '${PRIMARY_CMD} codex add' to add an account via OAuth.`);
671
+ return;
672
+ }
673
+
674
+ const activeAccountId = codexDivergence?.activeAccount?.accountId ?? null;
675
+ const cliAccountId = codexDivergence?.cliAccountId ?? null;
676
+ const cliLabel = codexDivergence?.cliLabel ?? null;
677
+ const divergenceDetected = codexDivergence?.diverged ?? false;
678
+ const nativeAccountId = cliAccountId && (!activeAccountId || cliAccountId !== activeAccountId)
679
+ ? cliAccountId
680
+ : null;
681
+
682
+ // Build account details for each account
683
+ const accountDetails = accounts.map(account => {
684
+ const profile = extractProfile(account.access);
685
+ const expiry = formatExpiryStatus(account.expires);
686
+
687
+ const isActive = activeLabel !== null && account.label === activeLabel;
688
+ const isNativeActive = !isActive && nativeAccountId !== null && account.accountId === nativeAccountId;
689
+
690
+ return {
691
+ label: account.label,
692
+ email: profile.email,
693
+ accountId: account.accountId,
694
+ planType: profile.planType,
695
+ expires: account.expires,
696
+ expiryStatus: expiry.status,
697
+ expiryDisplay: expiry.display,
698
+ source: account.source,
699
+ isActive,
700
+ isNativeActive,
701
+ };
702
+ });
703
+
704
+ // JSON output
705
+ if (flags.json) {
706
+ const output = {
707
+ accounts: accountDetails,
708
+ activeInfo: {
709
+ activeLabel,
710
+ activeAccountId,
711
+ activeStorePath: codexDivergence?.activeStorePath ?? null,
712
+ cliAccountId,
713
+ cliLabel,
714
+ divergence: divergenceDetected,
715
+ migrated: codexDivergence?.migrated ?? false,
716
+ local: flags.local ?? false,
717
+ },
718
+ };
719
+ console.log(JSON.stringify(output, null, 2));
720
+ return;
721
+ }
722
+
723
+ if (divergenceDetected) {
724
+ const activeLabelDisplay = activeLabel ?? "(none)";
725
+ const activeIdDisplay = activeAccountId ?? "(unknown)";
726
+ const cliLabelDisplay = cliLabel ?? "(unknown)";
727
+ const cliIdDisplay = cliAccountId ?? "(unknown)";
728
+ console.error(colorize("Warning: CLI auth diverged from activeLabel", YELLOW));
729
+ console.error(` Active: ${activeLabelDisplay} (${activeIdDisplay})`);
730
+ console.error(` CLI: ${cliLabelDisplay} (${cliIdDisplay})`);
731
+ console.error("");
732
+ console.error(`Run '${PRIMARY_CMD} codex sync' to push active account to CLI.`);
733
+ console.error("");
734
+ }
735
+
736
+ // Human-readable output with box styling
737
+ const lines = [];
738
+ if (accounts.length) {
739
+ lines.push(`Accounts (${accounts.length} total)`);
740
+ lines.push("");
741
+ }
742
+
743
+ for (let i = 0; i < accountDetails.length; i++) {
744
+ const detail = accountDetails[i];
745
+
746
+ // Active indicator:
747
+ // * = active account set by codex-quota
748
+ // ~ = native login (not set by us, but currently active in auth.json)
749
+ // = inactive
750
+ let activeMarker = " ";
751
+ let statusText = "";
752
+ if (detail.isActive) {
753
+ activeMarker = "*";
754
+ statusText = " [active]";
755
+ } else if (detail.isNativeActive) {
756
+ activeMarker = "~";
757
+ statusText = " [native]";
758
+ }
759
+
760
+ // Label and email with plan
761
+ const emailDisplay = detail.email ? ` <${detail.email}>` : "";
762
+ const planDisplay = detail.planType ? ` (${detail.planType})` : "";
763
+ lines.push(`${activeMarker} ${detail.label}${emailDisplay}${planDisplay}${statusText}`);
764
+
765
+ // Details line with expiry and source
766
+ const expiryColor = detail.expiryStatus === "expired" ? "Expired" :
767
+ detail.expiryStatus === "expiring" ? detail.expiryDisplay :
768
+ `Expires: ${detail.expiryDisplay}`;
769
+ lines.push(` ${expiryColor} | ${shortenPath(detail.source)}`);
770
+
771
+ // Add spacing between accounts (but not after the last one)
772
+ if (i < accountDetails.length - 1) {
773
+ lines.push("");
774
+ }
775
+ }
776
+
777
+ // Legend - show appropriate legend based on what markers are present
778
+ const hasActive = accountDetails.some(a => a.isActive);
779
+ const hasNativeActive = accountDetails.some(a => a.isNativeActive);
780
+
781
+ if (hasActive || hasNativeActive) {
782
+ lines.push("");
783
+ if (hasActive) {
784
+ lines.push("* = active (from activeLabel)");
785
+ }
786
+ if (hasNativeActive) {
787
+ lines.push(`~ = CLI auth (run '${PRIMARY_CMD} codex sync' to realign)`);
788
+ }
789
+ }
790
+
791
+ if (lines.length) {
792
+ const boxLines = drawBox(lines);
793
+ console.log(boxLines.join("\n"));
794
+ }
795
+
796
+ }
797
+
798
+ /**
799
+ * Handle Claude list subcommand - list Claude credentials
800
+ * @param {{ json: boolean, local?: boolean }} flags - Parsed flags
801
+ */
802
+ export async function handleClaudeList(flags) {
803
+ if (!flags.local) {
804
+ const importResult = await maybeImportClaudeOauthStores({ json: flags.json });
805
+ if (importResult.warnings.length && !flags.json) {
806
+ for (const warning of importResult.warnings) {
807
+ console.error(colorize(`Warning: ${warning}`, YELLOW));
808
+ }
809
+ }
810
+ }
811
+ const divergence = flags.local ? null : detectClaudeDivergence();
812
+ const activeLabel = divergence?.activeLabel ?? null;
813
+ const claudeAccounts = loadClaudeAccounts();
814
+
815
+ if (!claudeAccounts.length) {
816
+ if (flags.json) {
817
+ console.log(JSON.stringify({ accounts: [] }, null, 2));
818
+ return;
819
+ }
820
+ console.log("No Claude accounts found.");
821
+ console.log("\nSearched:");
822
+ console.log(" - CLAUDE_ACCOUNTS env var");
823
+ for (const p of CLAUDE_MULTI_ACCOUNT_PATHS) {
824
+ console.log(` - ${p}`);
825
+ }
826
+ console.log(`\nRun '${PRIMARY_CMD} claude add' to add a Claude credential.`);
827
+ return;
828
+ }
829
+
830
+ if (flags.json) {
831
+ const output = {
832
+ accounts: claudeAccounts.map(account => ({
833
+ label: account.label,
834
+ source: account.source,
835
+ hasSessionKey: Boolean(account.sessionKey ?? findClaudeSessionKey(account.cookies)),
836
+ hasOauthToken: Boolean(account.oauthToken),
837
+ orgId: account.orgId ?? null,
838
+ isActive: activeLabel !== null && account.label === activeLabel,
839
+ })),
840
+ activeInfo: {
841
+ activeLabel,
842
+ activeStorePath: divergence?.activeStorePath ?? null,
843
+ divergence: divergence?.diverged ?? false,
844
+ skipped: divergence?.skipped ?? false,
845
+ skipReason: divergence?.skipReason ?? null,
846
+ local: flags.local ?? false,
847
+ },
848
+ };
849
+ console.log(JSON.stringify(output, null, 2));
850
+ return;
851
+ }
852
+
853
+ if (divergence?.diverged) {
854
+ const divergedStores = divergence.stores
855
+ .filter(store => store.considered && store.matches === false)
856
+ .map(store => store.name);
857
+ const storeDisplay = divergedStores.length ? divergedStores.join(", ") : "one or more stores";
858
+ console.error(colorize(`Warning: Claude auth diverged from activeLabel (${activeLabel})`, YELLOW));
859
+ console.error(` Diverged stores: ${storeDisplay}`);
860
+ console.error("");
861
+ console.error(`Run '${PRIMARY_CMD} claude sync' to push active account to CLI.`);
862
+ console.error("");
863
+ } else if (divergence?.skipped && divergence.skipReason === "active-account-not-oauth" && activeLabel) {
864
+ console.error("Note: Active Claude account has no OAuth tokens; skipping divergence check.");
865
+ console.error("");
866
+ }
867
+
868
+ const claudeLines = [];
869
+ claudeLines.push(`Claude Accounts (${claudeAccounts.length} total)`);
870
+ claudeLines.push("");
871
+ for (let i = 0; i < claudeAccounts.length; i++) {
872
+ const account = claudeAccounts[i];
873
+ const isActive = activeLabel !== null && account.label === activeLabel;
874
+ const marker = isActive ? "*" : " ";
875
+ const statusText = isActive ? " [active]" : "";
876
+ const authParts = [];
877
+ if (account.sessionKey ?? findClaudeSessionKey(account.cookies)) {
878
+ authParts.push("sessionKey");
879
+ }
880
+ if (account.oauthToken) {
881
+ authParts.push("oauthToken");
882
+ }
883
+ const authDisplay = authParts.length ? authParts.join("+") : "unknown";
884
+ claudeLines.push(`${marker} ${account.label}${statusText}`);
885
+ claudeLines.push(` Auth: ${authDisplay} | ${shortenPath(account.source)}`);
886
+ if (i < claudeAccounts.length - 1) {
887
+ claudeLines.push("");
888
+ }
889
+ }
890
+ if (activeLabel !== null) {
891
+ claudeLines.push("");
892
+ claudeLines.push("* = active (from activeLabel)");
893
+ }
894
+ const claudeBox = drawBox(claudeLines);
895
+ console.log(claudeBox.join("\n"));
896
+ }
897
+
898
+ /**
899
+ * Prompt for confirmation using readline
900
+ * @param {string} message - Message to display
901
+ * @returns {Promise<boolean>} True if user confirms (y/Y), false otherwise
902
+ */
903
+ export async function handleRemove(args, flags) {
904
+ const label = args[0];
905
+ if (!label) {
906
+ if (flags.json) {
907
+ console.log(JSON.stringify({ success: false, error: "Missing required label argument" }, null, 2));
908
+ } else {
909
+ console.error(colorize(`Usage: ${PRIMARY_CMD} codex remove <label>`, RED));
910
+ console.error("Removes an account from the multi-account file.");
911
+ }
912
+ process.exit(1);
913
+ }
914
+
915
+ // Find the account
916
+ const account = findAccountByLabel(label);
917
+ if (!account) {
918
+ const availableLabels = getAllLabels();
919
+ if (flags.json) {
920
+ console.log(JSON.stringify({
921
+ success: false,
922
+ error: `Account "${label}" not found`,
923
+ availableLabels
924
+ }, null, 2));
925
+ } else {
926
+ console.error(colorize(`Account "${label}" not found.`, RED));
927
+ if (availableLabels.length) {
928
+ console.error(`Available labels: ${availableLabels.join(", ")}`);
929
+ } else {
930
+ console.error("No accounts configured.");
931
+ }
932
+ }
933
+ process.exit(1);
934
+ }
935
+
936
+ const source = account.source;
937
+
938
+ // Check source type
939
+ if (source === "env") {
940
+ if (flags.json) {
941
+ console.log(JSON.stringify({
942
+ success: false,
943
+ error: "Cannot remove account from CODEX_ACCOUNTS env var. Modify the env var directly."
944
+ }, null, 2));
945
+ } else {
946
+ console.error(colorize("Cannot remove account from CODEX_ACCOUNTS env var.", RED));
947
+ console.error("Modify the env var directly to remove this account.");
948
+ }
949
+ process.exit(1);
950
+ }
951
+
952
+ // Handle Codex CLI auth.json (single account file)
953
+ const codexAuthPath = getCodexCliAuthPath();
954
+ if (source === codexAuthPath) {
955
+ if (!flags.json) {
956
+ console.log(colorize("Warning: This will clear your Codex CLI authentication.", YELLOW));
957
+ console.log(`You will need to re-authenticate using 'codex auth' or '${PRIMARY_CMD} codex add'.`);
958
+ const confirmed = await promptConfirm("Continue?");
959
+ if (!confirmed) {
960
+ console.log("Cancelled.");
961
+ process.exit(0);
962
+ }
963
+ }
964
+
965
+ // Delete the auth.json file
966
+ try {
967
+ unlinkSync(codexAuthPath);
968
+ if (flags.json) {
969
+ console.log(JSON.stringify({
970
+ success: true,
971
+ label,
972
+ source: shortenPath(codexAuthPath),
973
+ message: "Codex CLI auth cleared"
974
+ }, null, 2));
975
+ } else {
976
+ const lines = [
977
+ colorize(`Removed account ${label}`, GREEN),
978
+ "",
979
+ `Deleted: ${shortenPath(codexAuthPath)}`,
980
+ ];
981
+ console.log(drawBox(lines).join("\n"));
982
+ }
983
+ } catch (err) {
984
+ if (flags.json) {
985
+ console.log(JSON.stringify({ success: false, error: err.message }, null, 2));
986
+ } else {
987
+ console.error(colorize(`Error removing auth file: ${err.message}`, RED));
988
+ }
989
+ process.exit(1);
990
+ }
991
+ return;
992
+ }
993
+
994
+ const removedWasActive = detectCodexDivergence().activeLabel === label;
995
+ let activeLabelCleared = false;
996
+ let activeLabelClearError = null;
997
+ let codexQuotaLabelCleared = false;
998
+ let codexQuotaClearError = null;
999
+
1000
+ // Handle multi-account files
1001
+ // Count accounts in the same source file
1002
+ const allAccounts = loadAllAccountsNoDedup();
1003
+ const accountsInSameFile = allAccounts.filter(a => a.source === source);
1004
+
1005
+ if (accountsInSameFile.length === 1) {
1006
+ if (!flags.json) {
1007
+ console.log(colorize("Warning: This is the only account in this file.", YELLOW));
1008
+ console.log(`The file will be deleted: ${shortenPath(source)}`);
1009
+ const confirmed = await promptConfirm("Continue?");
1010
+ if (!confirmed) {
1011
+ console.log("Cancelled.");
1012
+ process.exit(0);
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ // Read the file container directly (to preserve any extra root fields)
1018
+ const container = readMultiAccountContainer(source);
1019
+ if (container.rootType === "invalid") {
1020
+ if (flags.json) {
1021
+ console.log(JSON.stringify({ success: false, error: `Failed to parse ${source}` }, null, 2));
1022
+ } else {
1023
+ console.error(colorize(`Error reading ${source}`, RED));
1024
+ }
1025
+ process.exit(1);
1026
+ }
1027
+ const existingAccounts = container.accounts;
1028
+
1029
+ // Filter out the account with matching label
1030
+ const updatedAccounts = existingAccounts.filter(a => a.label !== label);
1031
+
1032
+ if (updatedAccounts.length === existingAccounts.length) {
1033
+ // This shouldn't happen if findAccountByLabel worked, but handle it gracefully
1034
+ if (flags.json) {
1035
+ console.log(JSON.stringify({ success: false, error: `Account "${label}" not found in ${source}` }, null, 2));
1036
+ } else {
1037
+ console.error(colorize(`Account "${label}" not found in ${shortenPath(source)}`, RED));
1038
+ }
1039
+ process.exit(1);
1040
+ }
1041
+
1042
+ // Write back or delete
1043
+ try {
1044
+ const fileDeleted = updatedAccounts.length === 0;
1045
+ if (fileDeleted) {
1046
+ // No accounts left - delete the file
1047
+ unlinkSync(source);
1048
+ } else {
1049
+ // Write updated accounts atomically
1050
+ writeMultiAccountContainer(source, container, updatedAccounts, {}, { mode: 0o600 });
1051
+ }
1052
+
1053
+ if (removedWasActive) {
1054
+ try {
1055
+ const cleared = setCodexActiveLabel(null);
1056
+ activeLabelCleared = cleared.updated;
1057
+ } catch (err) {
1058
+ activeLabelClearError = err?.message ?? String(err);
1059
+ }
1060
+ }
1061
+
1062
+ try {
1063
+ const cleared = clearCodexQuotaLabelForRemovedAccount(account);
1064
+ codexQuotaLabelCleared = cleared.updated;
1065
+ } catch (err) {
1066
+ codexQuotaClearError = err?.message ?? String(err);
1067
+ }
1068
+
1069
+ if (flags.json) {
1070
+ const output = {
1071
+ success: true,
1072
+ label,
1073
+ source: shortenPath(source),
1074
+ };
1075
+ if (fileDeleted) {
1076
+ output.message = "File deleted (no accounts remaining)";
1077
+ } else {
1078
+ output.remainingAccounts = updatedAccounts.length;
1079
+ }
1080
+ if (removedWasActive) {
1081
+ output.activeLabelCleared = activeLabelCleared;
1082
+ }
1083
+ if (activeLabelClearError) {
1084
+ output.activeLabelError = activeLabelClearError;
1085
+ }
1086
+ if (codexQuotaLabelCleared) {
1087
+ output.codexQuotaLabelCleared = true;
1088
+ }
1089
+ if (codexQuotaClearError) {
1090
+ output.codexQuotaLabelError = codexQuotaClearError;
1091
+ }
1092
+ console.log(JSON.stringify(output, null, 2));
1093
+ return;
1094
+ }
1095
+
1096
+ if (activeLabelClearError) {
1097
+ console.error(colorize(`Warning: Failed to clear activeLabel: ${activeLabelClearError}`, YELLOW));
1098
+ }
1099
+ if (codexQuotaClearError) {
1100
+ console.error(colorize(`Warning: Failed to clear codex_quota_label: ${codexQuotaClearError}`, YELLOW));
1101
+ }
1102
+
1103
+ if (fileDeleted) {
1104
+ const lines = [
1105
+ colorize(`Removed account ${label}`, GREEN),
1106
+ "",
1107
+ `Deleted: ${shortenPath(source)} (no accounts remaining)`,
1108
+ ];
1109
+ console.log(drawBox(lines).join("\n"));
1110
+ } else {
1111
+ const lines = [
1112
+ colorize(`Removed account ${label}`, GREEN),
1113
+ "",
1114
+ `Updated: ${shortenPath(source)} (${updatedAccounts.length} account(s) remaining)`,
1115
+ ];
1116
+ console.log(drawBox(lines).join("\n"));
1117
+ }
1118
+ } catch (err) {
1119
+ if (flags.json) {
1120
+ console.log(JSON.stringify({ success: false, error: err.message }, null, 2));
1121
+ } else {
1122
+ console.error(colorize(`Error writing ${shortenPath(source)}: ${err.message}`, RED));
1123
+ }
1124
+ process.exit(1);
1125
+ }
1126
+ }
1127
+
1128
+ /**
1129
+ * Handle Claude remove subcommand - remove a Claude account from storage
1130
+ * @param {string[]} args - Non-flag arguments (label is required)
1131
+ * @param {{ json: boolean }} flags - Parsed flags
1132
+ */
1133
+ export async function handleClaudeRemove(args, flags) {
1134
+ const label = args[0];
1135
+ if (!label) {
1136
+ if (flags.json) {
1137
+ console.log(JSON.stringify({ success: false, error: "Missing required label argument" }, null, 2));
1138
+ } else {
1139
+ console.error(colorize(`Usage: ${PRIMARY_CMD} claude remove <label>`, RED));
1140
+ console.error("Removes a Claude credential from the multi-account file.");
1141
+ }
1142
+ process.exit(1);
1143
+ }
1144
+
1145
+ const account = findClaudeAccountByLabel(label);
1146
+ if (!account) {
1147
+ const availableLabels = getClaudeLabels();
1148
+ if (flags.json) {
1149
+ console.log(JSON.stringify({
1150
+ success: false,
1151
+ error: `Claude account "${label}" not found`,
1152
+ availableLabels,
1153
+ }, null, 2));
1154
+ } else {
1155
+ console.error(colorize(`Claude account "${label}" not found.`, RED));
1156
+ if (availableLabels.length) {
1157
+ console.error(`Available labels: ${availableLabels.join(", ")}`);
1158
+ } else {
1159
+ console.error("No Claude accounts configured.");
1160
+ }
1161
+ }
1162
+ process.exit(1);
1163
+ }
1164
+
1165
+ if (account.source === "env") {
1166
+ if (flags.json) {
1167
+ console.log(JSON.stringify({
1168
+ success: false,
1169
+ error: "Cannot remove account from CLAUDE_ACCOUNTS env var. Modify the env var directly.",
1170
+ }, null, 2));
1171
+ } else {
1172
+ console.error(colorize("Cannot remove account from CLAUDE_ACCOUNTS env var.", RED));
1173
+ console.error("Modify the env var directly to remove this account.");
1174
+ }
1175
+ process.exit(1);
1176
+ }
1177
+
1178
+ const source = account.source;
1179
+ if (!CLAUDE_MULTI_ACCOUNT_PATHS.includes(source)) {
1180
+ if (flags.json) {
1181
+ console.log(JSON.stringify({
1182
+ success: false,
1183
+ error: `Cannot remove Claude account from ${source}. Remove it from the owning tool instead.`,
1184
+ }, null, 2));
1185
+ } else {
1186
+ console.error(colorize(`Cannot remove Claude account from ${shortenPath(source)}.`, RED));
1187
+ console.error("Remove it from the owning tool instead.");
1188
+ }
1189
+ process.exit(1);
1190
+ }
1191
+
1192
+ const removedWasActive = getClaudeActiveLabelInfo().activeLabel === label;
1193
+ let activeLabelCleared = false;
1194
+ let activeLabelClearError = null;
1195
+
1196
+ const container = readMultiAccountContainer(source);
1197
+ if (container.rootType === "invalid") {
1198
+ if (flags.json) {
1199
+ console.log(JSON.stringify({ success: false, error: `Failed to parse ${source}` }, null, 2));
1200
+ } else {
1201
+ console.error(colorize(`Error reading ${shortenPath(source)}`, RED));
1202
+ }
1203
+ process.exit(1);
1204
+ }
1205
+ const existingAccounts = container.accounts;
1206
+
1207
+ const updatedAccounts = existingAccounts.filter(a => a.label !== label);
1208
+ if (updatedAccounts.length === existingAccounts.length) {
1209
+ if (flags.json) {
1210
+ console.log(JSON.stringify({ success: false, error: `Claude account "${label}" not found in ${source}` }, null, 2));
1211
+ } else {
1212
+ console.error(colorize(`Claude account "${label}" not found in ${shortenPath(source)}`, RED));
1213
+ }
1214
+ process.exit(1);
1215
+ }
1216
+
1217
+ if (updatedAccounts.length === 0 && !flags.json) {
1218
+ console.log(colorize("Warning: This is the only Claude account in this file.", YELLOW));
1219
+ console.log(`The file will be deleted: ${shortenPath(source)}`);
1220
+ const confirmed = await promptConfirm("Continue?");
1221
+ if (!confirmed) {
1222
+ console.log("Cancelled.");
1223
+ process.exit(0);
1224
+ }
1225
+ }
1226
+
1227
+ try {
1228
+ const fileDeleted = updatedAccounts.length === 0;
1229
+ if (fileDeleted) {
1230
+ unlinkSync(source);
1231
+ } else {
1232
+ writeMultiAccountContainer(source, container, updatedAccounts, {}, { mode: 0o600 });
1233
+ }
1234
+
1235
+ if (removedWasActive) {
1236
+ try {
1237
+ const cleared = setClaudeActiveLabel(null);
1238
+ activeLabelCleared = cleared.updated;
1239
+ } catch (err) {
1240
+ activeLabelClearError = err?.message ?? String(err);
1241
+ }
1242
+ }
1243
+
1244
+ if (flags.json) {
1245
+ const output = {
1246
+ success: true,
1247
+ label,
1248
+ source: shortenPath(source),
1249
+ };
1250
+ if (fileDeleted) {
1251
+ output.message = "File deleted (no accounts remaining)";
1252
+ } else {
1253
+ output.remainingAccounts = updatedAccounts.length;
1254
+ }
1255
+ if (removedWasActive) {
1256
+ output.activeLabelCleared = activeLabelCleared;
1257
+ }
1258
+ if (activeLabelClearError) {
1259
+ output.activeLabelError = activeLabelClearError;
1260
+ }
1261
+ console.log(JSON.stringify(output, null, 2));
1262
+ return;
1263
+ }
1264
+
1265
+ if (activeLabelClearError) {
1266
+ console.error(colorize(`Warning: Failed to clear activeLabel: ${activeLabelClearError}`, YELLOW));
1267
+ }
1268
+
1269
+ if (fileDeleted) {
1270
+ const lines = [
1271
+ colorize(`Removed Claude account ${label}`, GREEN),
1272
+ "",
1273
+ `Deleted: ${shortenPath(source)} (no accounts remaining)`,
1274
+ ];
1275
+ console.log(drawBox(lines).join("\n"));
1276
+ } else {
1277
+ const lines = [
1278
+ colorize(`Removed Claude account ${label}`, GREEN),
1279
+ "",
1280
+ `Updated: ${shortenPath(source)} (${updatedAccounts.length} account(s) remaining)`,
1281
+ ];
1282
+ console.log(drawBox(lines).join("\n"));
1283
+ }
1284
+ } catch (err) {
1285
+ if (flags.json) {
1286
+ console.log(JSON.stringify({ success: false, error: err.message }, null, 2));
1287
+ } else {
1288
+ console.error(colorize(`Error writing ${shortenPath(source)}: ${err.message}`, RED));
1289
+ }
1290
+ process.exit(1);
1291
+ }
1292
+ }
1293
+
1294
+ /**
1295
+ * Handle Claude switch subcommand - switch Claude Code/OpenCode/pi credentials
1296
+ * @param {string[]} args - Non-flag arguments (label is required)
1297
+ * @param {{ json: boolean }} flags - Parsed flags
1298
+ */
1299
+ export async function handleClaudeSwitch(args, flags) {
1300
+ const label = args[0];
1301
+ if (!label) {
1302
+ if (flags.json) {
1303
+ console.log(JSON.stringify({ success: false, error: "Missing required label argument" }, null, 2));
1304
+ } else {
1305
+ console.error(colorize(`Usage: ${PRIMARY_CMD} claude switch <label>`, RED));
1306
+ console.error("Switches Claude credentials in Claude Code, OpenCode, and pi.");
1307
+ }
1308
+ process.exit(1);
1309
+ }
1310
+
1311
+ const account = findClaudeAccountByLabel(label);
1312
+ if (!account) {
1313
+ const availableLabels = getClaudeLabels();
1314
+ if (flags.json) {
1315
+ console.log(JSON.stringify({
1316
+ success: false,
1317
+ error: `Claude account "${label}" not found`,
1318
+ availableLabels,
1319
+ }, null, 2));
1320
+ } else {
1321
+ console.error(colorize(`Claude account "${label}" not found.`, RED));
1322
+ if (availableLabels.length) {
1323
+ console.error(`Available: ${availableLabels.join(", ")}`);
1324
+ } else {
1325
+ console.error(`Run '${PRIMARY_CMD} claude add' to add a Claude credential.`);
1326
+ }
1327
+ }
1328
+ process.exit(1);
1329
+ }
1330
+
1331
+ if (!account.oauthToken) {
1332
+ const message = "Claude switch requires an OAuth token. Re-add with --oauth or provide an oauthToken.";
1333
+ if (flags.json) {
1334
+ console.log(JSON.stringify({ success: false, error: message }, null, 2));
1335
+ } else {
1336
+ console.error(colorize(`Error: ${message}`, RED));
1337
+ }
1338
+ process.exit(1);
1339
+ }
1340
+
1341
+ let activeLabelPath = null;
1342
+ let activeLabelError = null;
1343
+ if (CLAUDE_MULTI_ACCOUNT_PATHS.includes(account.source)) {
1344
+ try {
1345
+ const activeUpdate = setClaudeActiveLabel(label);
1346
+ activeLabelPath = activeUpdate.path;
1347
+ } catch (err) {
1348
+ activeLabelError = err?.message ?? String(err);
1349
+ }
1350
+ }
1351
+
1352
+ const credentialsUpdate = updateClaudeCredentials(account);
1353
+ if (credentialsUpdate.error) {
1354
+ if (flags.json) {
1355
+ console.log(JSON.stringify({ success: false, error: credentialsUpdate.error }, null, 2));
1356
+ } else {
1357
+ console.error(colorize(`Error: ${credentialsUpdate.error}`, RED));
1358
+ }
1359
+ process.exit(1);
1360
+ }
1361
+
1362
+ const opencodeUpdate = updateOpencodeClaudeAuth(account);
1363
+ if (opencodeUpdate.error && !flags.json) {
1364
+ console.error(colorize(`Warning: ${opencodeUpdate.error}`, YELLOW));
1365
+ }
1366
+ const piUpdate = updatePiClaudeAuth(account);
1367
+ if (piUpdate.error && !flags.json) {
1368
+ console.error(colorize(`Warning: ${piUpdate.error}`, YELLOW));
1369
+ }
1370
+
1371
+ if (flags.json) {
1372
+ const output = {
1373
+ success: true,
1374
+ label,
1375
+ claudeCredentialsPath: credentialsUpdate.path,
1376
+ };
1377
+ if (activeLabelPath) {
1378
+ output.activeLabelPath = activeLabelPath;
1379
+ }
1380
+ if (activeLabelError) {
1381
+ output.activeLabelError = activeLabelError;
1382
+ }
1383
+ if (opencodeUpdate.updated) {
1384
+ output.opencodeAuthPath = opencodeUpdate.path;
1385
+ } else if (opencodeUpdate.error) {
1386
+ output.opencodeAuthError = opencodeUpdate.error;
1387
+ }
1388
+ if (piUpdate.updated) {
1389
+ output.piAuthPath = piUpdate.path;
1390
+ } else if (piUpdate.error) {
1391
+ output.piAuthError = piUpdate.error;
1392
+ }
1393
+ console.log(JSON.stringify(output, null, 2));
1394
+ return;
1395
+ }
1396
+
1397
+ if (activeLabelError) {
1398
+ console.error(colorize(`Warning: Failed to update activeLabel: ${activeLabelError}`, YELLOW));
1399
+ }
1400
+ const lines = [
1401
+ colorize(`Switched Claude credentials to ${label}`, GREEN),
1402
+ "",
1403
+ `Claude Code: ${shortenPath(credentialsUpdate.path)}`,
1404
+ ];
1405
+ if (activeLabelPath) {
1406
+ lines.push(`Active label: ${shortenPath(activeLabelPath)}`);
1407
+ }
1408
+ if (opencodeUpdate.updated) {
1409
+ lines.push(`OpenCode: ${shortenPath(opencodeUpdate.path)}`);
1410
+ }
1411
+ if (piUpdate.updated) {
1412
+ lines.push(`pi: ${shortenPath(piUpdate.path)}`);
1413
+ }
1414
+ console.log(drawBox(lines).join("\n"));
1415
+ }
1416
+
1417
+ /**
1418
+ * Handle Claude sync subcommand - bi-directional sync for activeLabel account
1419
+ * 1. Pull: if a CLI store has the same refresh token but newer access/expires, pull it back
1420
+ * 2. Push: write the (now freshest) account tokens to all CLI auth files
1421
+ * @param {string[]} args - Non-flag arguments (unused)
1422
+ * @param {{ json: boolean, dryRun?: boolean }} flags - Parsed flags
1423
+ */
1424
+ export async function handleClaudeAdd(args, flags) {
1425
+ let label = args[0] || null;
1426
+ try {
1427
+ // Check for conflicting flags
1428
+ if (flags.oauth && flags.manual) {
1429
+ throw new Error("Cannot use both --oauth and --manual flags. Choose one authentication method.");
1430
+ }
1431
+
1432
+ const existingAccounts = loadClaudeAccounts();
1433
+ const existingLabels = new Set(existingAccounts.map(a => a.label));
1434
+
1435
+ // Prompt for label if not provided
1436
+ if (!label) {
1437
+ label = (await promptInput("Label (e.g., work, personal): ")).trim();
1438
+ }
1439
+ if (!label) {
1440
+ throw new Error("Label is required");
1441
+ }
1442
+ if (!/^[a-zA-Z0-9_-]+$/.test(label)) {
1443
+ throw new Error(`Invalid label "${label}". Use only letters, numbers, hyphens, and underscores.`);
1444
+ }
1445
+ if (existingLabels.has(label)) {
1446
+ throw new Error(`Label "${label}" already exists. Choose a different label.`);
1447
+ }
1448
+
1449
+ // Determine authentication method
1450
+ let useOAuth = flags.oauth;
1451
+ if (!flags.oauth && !flags.manual) {
1452
+ // Prompt for choice
1453
+ console.log("\nChoose authentication method:");
1454
+ console.log(" [1] OAuth (recommended) - Authenticate via browser");
1455
+ console.log(" [2] Manual - Paste sessionKey/token directly\n");
1456
+ const choice = (await promptInput("Enter choice (1 or 2): ")).trim();
1457
+ useOAuth = choice === "1";
1458
+ }
1459
+
1460
+ let newAccount;
1461
+ let viaMethod;
1462
+
1463
+ if (useOAuth) {
1464
+ // OAuth browser flow
1465
+ const tokens = await handleClaudeOAuthFlow({ noBrowser: flags.noBrowser });
1466
+ newAccount = {
1467
+ label,
1468
+ sessionKey: null,
1469
+ oauthToken: tokens.accessToken,
1470
+ oauthRefreshToken: tokens.refreshToken,
1471
+ oauthExpiresAt: tokens.expiresAt,
1472
+ oauthScopes: tokens.scopes,
1473
+ cfClearance: null,
1474
+ orgId: null,
1475
+ };
1476
+ viaMethod = "via OAuth";
1477
+ } else {
1478
+ // Manual entry flow
1479
+ console.log("\nPaste your Claude sessionKey or OAuth token.");
1480
+ const sessionKeyInput = await promptInput("sessionKey (sk-ant-...): ", { allowEmpty: true });
1481
+ const oauthTokenInput = await promptInput("oauthToken (optional): ", { allowEmpty: true });
1482
+ const cfClearanceInput = await promptInput("cfClearance (optional): ", { allowEmpty: true });
1483
+ const orgIdInput = await promptInput("orgId (optional): ", { allowEmpty: true });
1484
+
1485
+ let parsedInput = null;
1486
+ if (sessionKeyInput && sessionKeyInput.trim().startsWith("{")) {
1487
+ try {
1488
+ parsedInput = JSON.parse(sessionKeyInput);
1489
+ } catch {
1490
+ parsedInput = null;
1491
+ }
1492
+ }
1493
+
1494
+ const sessionKey = findClaudeSessionKey(parsedInput ?? sessionKeyInput) ?? null;
1495
+ const oauthToken = oauthTokenInput?.trim()
1496
+ || parsedInput?.claudeAiOauth?.accessToken
1497
+ || parsedInput?.claude_ai_oauth?.accessToken
1498
+ || parsedInput?.accessToken
1499
+ || parsedInput?.access_token
1500
+ || null;
1501
+ const cfClearance = cfClearanceInput?.trim() || null;
1502
+ const orgId = orgIdInput?.trim() || null;
1503
+
1504
+ if (!sessionKey && !oauthToken) {
1505
+ throw new Error("Provide at least a sessionKey or an OAuth token.");
1506
+ }
1507
+
1508
+ newAccount = {
1509
+ label,
1510
+ sessionKey,
1511
+ oauthToken,
1512
+ cfClearance,
1513
+ orgId,
1514
+ };
1515
+ viaMethod = "";
1516
+ }
1517
+
1518
+ const { path: targetPath, container } = readClaudeActiveStoreContainer();
1519
+ const accounts = [...container.accounts, newAccount];
1520
+ writeMultiAccountContainer(targetPath, container, accounts, {}, { mode: 0o600 });
1521
+
1522
+ if (flags.json) {
1523
+ console.log(JSON.stringify({
1524
+ success: true,
1525
+ label,
1526
+ method: useOAuth ? "oauth" : "manual",
1527
+ source: targetPath,
1528
+ }, null, 2));
1529
+ return;
1530
+ }
1531
+
1532
+ const credentialText = viaMethod ? `Added Claude credential ${label} (${viaMethod})` : `Added Claude credential ${label}`;
1533
+ const lines = [
1534
+ colorize(credentialText, GREEN),
1535
+ "",
1536
+ `Saved to: ${shortenPath(targetPath)}`,
1537
+ "",
1538
+ `Run '${PRIMARY_CMD} claude quota' to check Claude usage`,
1539
+ ];
1540
+ console.log(drawBox(lines).join("\n"));
1541
+ } catch (error) {
1542
+ if (flags.json) {
1543
+ console.log(JSON.stringify({
1544
+ success: false,
1545
+ error: error.message,
1546
+ }, null, 2));
1547
+ } else {
1548
+ console.error(colorize(`Error: ${error.message}`, RED));
1549
+ }
1550
+ process.exit(1);
1551
+ }
1552
+ }
1553
+
1554
+ /**
1555
+ * Handle Claude reauth subcommand - re-authenticate an existing Claude account via OAuth browser flow
1556
+ * This updates the existing account's tokens without changing the label
1557
+ * @param {string[]} args - Non-flag arguments (label is required)
1558
+ * @param {{ json: boolean, noBrowser: boolean }} flags - Parsed flags
1559
+ */
1560
+ export async function handleClaudeReauth(args, flags) {
1561
+ const label = args[0];
1562
+ if (!label) {
1563
+ if (flags.json) {
1564
+ console.log(JSON.stringify({ success: false, error: "Missing required label argument" }, null, 2));
1565
+ } else {
1566
+ console.error(colorize(`Usage: ${PRIMARY_CMD} claude reauth <label>`, RED));
1567
+ console.error("Re-authenticates an existing Claude account via OAuth browser flow.");
1568
+ }
1569
+ process.exit(1);
1570
+ }
1571
+
1572
+ try {
1573
+ // 1. Find existing account by label
1574
+ const existingAccount = findClaudeAccountByLabel(label);
1575
+ if (!existingAccount) {
1576
+ const availableLabels = getClaudeLabels();
1577
+ if (flags.json) {
1578
+ console.log(JSON.stringify({
1579
+ success: false,
1580
+ error: `Claude account "${label}" not found`,
1581
+ availableLabels,
1582
+ }, null, 2));
1583
+ } else if (availableLabels.length === 0) {
1584
+ console.error(colorize(`Claude account "${label}" not found. No accounts configured.`, RED));
1585
+ console.error(`Run '${PRIMARY_CMD} claude add' to add an account.`);
1586
+ } else {
1587
+ console.error(colorize(`Claude account "${label}" not found.`, RED));
1588
+ console.error(`Available: ${availableLabels.join(", ")}`);
1589
+ }
1590
+ process.exit(1);
1591
+ }
1592
+
1593
+ const source = existingAccount.source;
1594
+
1595
+ // 2. Check if account can be re-authenticated (must be in a multi-account file)
1596
+ if (source === "env") {
1597
+ if (flags.json) {
1598
+ console.log(JSON.stringify({
1599
+ success: false,
1600
+ error: "Cannot re-authenticate account from CLAUDE_ACCOUNTS env var. Modify the env var directly.",
1601
+ }, null, 2));
1602
+ } else {
1603
+ console.error(colorize("Cannot re-authenticate account from CLAUDE_ACCOUNTS env var.", RED));
1604
+ console.error("Modify the env var directly to update this account.");
1605
+ }
1606
+ process.exit(1);
1607
+ }
1608
+
1609
+ if (!CLAUDE_MULTI_ACCOUNT_PATHS.includes(source)) {
1610
+ if (flags.json) {
1611
+ console.log(JSON.stringify({
1612
+ success: false,
1613
+ error: `Cannot re-authenticate account from ${source}. Use the owning tool to re-authenticate.`,
1614
+ }, null, 2));
1615
+ } else {
1616
+ console.error(colorize(`Cannot re-authenticate account from ${shortenPath(source)}.`, RED));
1617
+ console.error("Use the owning tool to re-authenticate this account.");
1618
+ }
1619
+ process.exit(1);
1620
+ }
1621
+
1622
+ // 3. Run OAuth flow
1623
+ console.log(`Re-authenticating Claude account "${label}"...`);
1624
+ const tokens = await handleClaudeOAuthFlow({ noBrowser: flags.noBrowser });
1625
+
1626
+ // 4. Update the account entry in the source file
1627
+ const container = readMultiAccountContainer(source);
1628
+ if (container.rootType === "invalid") {
1629
+ throw new Error(`Failed to parse ${source}`);
1630
+ }
1631
+
1632
+ const updatedAccounts = container.accounts.map(entry => {
1633
+ if (!entry || typeof entry !== "object" || entry.label !== label) {
1634
+ return entry;
1635
+ }
1636
+ // Preserve any extra fields from the existing entry
1637
+ return {
1638
+ ...entry,
1639
+ oauthToken: tokens.accessToken,
1640
+ oauthRefreshToken: tokens.refreshToken,
1641
+ oauthExpiresAt: tokens.expiresAt,
1642
+ oauthScopes: tokens.scopes,
1643
+ };
1644
+ });
1645
+
1646
+ writeMultiAccountContainer(source, container, updatedAccounts, {}, { mode: 0o600 });
1647
+
1648
+ // 5. Update CLI auth files if this account is active
1649
+ const activeInfo = getClaudeActiveLabelInfo();
1650
+ if (activeInfo.activeLabel === label) {
1651
+ // This is the active account - sync to CLI auth files
1652
+ const updatedAccount = {
1653
+ oauthToken: tokens.accessToken,
1654
+ oauthRefreshToken: tokens.refreshToken,
1655
+ oauthExpiresAt: tokens.expiresAt,
1656
+ oauthScopes: tokens.scopes,
1657
+ };
1658
+
1659
+ updateClaudeCredentials(updatedAccount);
1660
+ updateOpencodeClaudeAuth(updatedAccount);
1661
+ updatePiClaudeAuth(updatedAccount);
1662
+ }
1663
+
1664
+ // 6. Print success message
1665
+ if (flags.json) {
1666
+ console.log(JSON.stringify({
1667
+ success: true,
1668
+ label,
1669
+ source,
1670
+ }, null, 2));
1671
+ } else {
1672
+ const lines = [
1673
+ colorize(`Re-authenticated Claude account ${label}`, GREEN),
1674
+ "",
1675
+ `Updated: ${shortenPath(source)}`,
1676
+ ];
1677
+ if (activeInfo.activeLabel === label) {
1678
+ lines.push("");
1679
+ lines.push("CLI auth files also updated (active account)");
1680
+ }
1681
+ const boxLines = drawBox(lines);
1682
+ console.log(boxLines.join("\n"));
1683
+ }
1684
+ } catch (error) {
1685
+ if (flags.json) {
1686
+ console.log(JSON.stringify({
1687
+ success: false,
1688
+ error: error.message,
1689
+ }, null, 2));
1690
+ } else {
1691
+ console.error(colorize(`Error: ${error.message}`, RED));
1692
+ }
1693
+ process.exit(1);
1694
+ }
1695
+ }
1696
+
1697
+ /**
1698
+ * Handle Codex subcommand entrypoint
1699
+ * @param {string[]} args - Codex subcommand args
1700
+ * @param {{ json: boolean, noBrowser: boolean, noColor: boolean }} flags - Parsed flags
1701
+ */
1702
+ export async function handleCodex(args, flags) {
1703
+ const subcommand = args[0];
1704
+ const subArgs = args.slice(1);
1705
+
1706
+ if (!subcommand) {
1707
+ printHelpCodex();
1708
+ return;
1709
+ }
1710
+
1711
+ switch (subcommand) {
1712
+ case "quota":
1713
+ await handleQuota(subArgs, flags, "codex");
1714
+ break;
1715
+ case "add":
1716
+ await handleAdd(subArgs, flags);
1717
+ break;
1718
+ case "reauth":
1719
+ await handleCodexReauth(subArgs, flags);
1720
+ break;
1721
+ case "switch":
1722
+ await handleSwitch(subArgs, flags);
1723
+ break;
1724
+ case "sync":
1725
+ await handleCodexSync(subArgs, flags);
1726
+ break;
1727
+ case "list":
1728
+ await handleList(flags);
1729
+ break;
1730
+ case "remove":
1731
+ await handleRemove(subArgs, flags);
1732
+ break;
1733
+ case "help":
1734
+ printHelpCodex();
1735
+ break;
1736
+ default:
1737
+ printHelpCodex();
1738
+ process.exit(1);
1739
+ }
1740
+ }
1741
+
1742
+ /**
1743
+ * Handle Claude subcommand entrypoint
1744
+ * @param {string[]} args - Claude subcommand args
1745
+ * @param {{ json: boolean, noBrowser: boolean, oauth: boolean, manual: boolean }} flags - Parsed flags
1746
+ */
1747
+ export async function handleClaude(args, flags) {
1748
+ const subcommand = args[0];
1749
+ const subArgs = args.slice(1);
1750
+
1751
+ if (!subcommand) {
1752
+ printHelpClaude();
1753
+ return;
1754
+ }
1755
+
1756
+ switch (subcommand) {
1757
+ case "quota":
1758
+ await handleQuota(subArgs, flags, "claude");
1759
+ break;
1760
+ case "add":
1761
+ await handleClaudeAdd(subArgs, flags);
1762
+ break;
1763
+ case "reauth":
1764
+ await handleClaudeReauth(subArgs, flags);
1765
+ break;
1766
+ case "list":
1767
+ await handleClaudeList(flags);
1768
+ break;
1769
+ case "switch":
1770
+ await handleClaudeSwitch(subArgs, flags);
1771
+ break;
1772
+ case "sync":
1773
+ await handleClaudeSync(subArgs, flags);
1774
+ break;
1775
+ case "remove":
1776
+ await handleClaudeRemove(subArgs, flags);
1777
+ break;
1778
+ case "help":
1779
+ printHelpClaude();
1780
+ break;
1781
+ default:
1782
+ printHelpClaude();
1783
+ process.exit(1);
1784
+ }
1785
+ }
1786
+
1787
+ /**
1788
+ * Handle quota subcommand (default behavior)
1789
+ * By default, shows both Codex and Claude accounts
1790
+ * @param {string[]} args - Non-flag arguments (e.g., label filter)
1791
+ * @param {{ json: boolean, local?: boolean }} flags - Parsed flags
1792
+ * @param {"all" | "codex" | "claude"} scope - Which accounts to show
1793
+ */
1794
+ export async function handleQuota(args, flags, scope = "all") {
1795
+ const labelFilter = args[0];
1796
+ const localMode = Boolean(flags.local);
1797
+
1798
+ // Determine which account types to show:
1799
+ // - scope "all": show both (default)
1800
+ // - scope "codex": show only Codex
1801
+ // - scope "claude": show only Claude
1802
+ const showCodex = scope === "all" || scope === "codex";
1803
+ const showClaude = scope === "all" || scope === "claude";
1804
+
1805
+ const codexDivergence = showCodex && !localMode ? detectCodexDivergence({ allowMigration: false }) : null;
1806
+ const codexActiveLabel = codexDivergence?.activeLabel ?? null;
1807
+ const allAccounts = showCodex ? loadAllAccounts(codexActiveLabel, { local: localMode }) : [];
1808
+ const hasOpenAiAccounts = allAccounts.length > 0;
1809
+ const claudeDivergence = showClaude && !localMode ? detectClaudeDivergence() : null;
1810
+
1811
+ // Check if we have any accounts to show
1812
+ if (!hasOpenAiAccounts && !showClaude) {
1813
+ if (flags.json) {
1814
+ console.log(JSON.stringify({
1815
+ success: false,
1816
+ error: "No Codex accounts found",
1817
+ searchedLocations: [
1818
+ "CODEX_ACCOUNTS env var",
1819
+ ...MULTI_ACCOUNT_PATHS,
1820
+ getCodexCliAuthPath(),
1821
+ ],
1822
+ }, null, 2));
1823
+ } else {
1824
+ console.error(colorize("No Codex accounts found.", RED));
1825
+ console.error("\nSearched:");
1826
+ console.error(" - CODEX_ACCOUNTS env var");
1827
+ for (const p of MULTI_ACCOUNT_PATHS) {
1828
+ console.error(` - ${p}`);
1829
+ }
1830
+ console.error(` - ${getCodexCliAuthPath()}`);
1831
+ console.error(`\nRun '${PRIMARY_CMD} codex add' to add an account.`);
1832
+ }
1833
+ process.exit(1);
1834
+ }
1835
+
1836
+ let accounts = [];
1837
+ if (hasOpenAiAccounts && showCodex) {
1838
+ accounts = labelFilter
1839
+ ? allAccounts.filter(a => a.label === labelFilter)
1840
+ : allAccounts;
1841
+ }
1842
+
1843
+ if (labelFilter && showCodex && !accounts.length && hasOpenAiAccounts) {
1844
+ if (flags.json) {
1845
+ console.log(JSON.stringify({
1846
+ success: false,
1847
+ error: `Account "${labelFilter}" not found`,
1848
+ availableLabels: allAccounts.map(a => a.label),
1849
+ }, null, 2));
1850
+ } else {
1851
+ console.error(colorize(`Account "${labelFilter}" not found.`, RED));
1852
+ console.error("Available:", allAccounts.map(a => a.label).join(", "));
1853
+ }
1854
+ process.exit(1);
1855
+ }
1856
+
1857
+ const results = [];
1858
+
1859
+ for (const account of accounts) {
1860
+ const tokenOk = await ensureFreshToken(account, allAccounts);
1861
+ if (!tokenOk) {
1862
+ results.push({ account, usage: { error: "Token refresh failed - re-auth required" } });
1863
+ continue;
1864
+ }
1865
+ const usage = await fetchUsage(account);
1866
+ results.push({ account, usage });
1867
+ }
1868
+
1869
+ let claudeResults = null;
1870
+ if (showClaude) {
1871
+ if (!localMode) {
1872
+ const importResult = await maybeImportClaudeOauthStores({ json: flags.json });
1873
+ if (importResult.warnings.length && !flags.json) {
1874
+ for (const warning of importResult.warnings) {
1875
+ console.error(colorize(`Warning: ${warning}`, YELLOW));
1876
+ }
1877
+ }
1878
+ }
1879
+ const wantsClaudeLabel = scope === "claude" && Boolean(labelFilter);
1880
+ const oauthAccounts = loadAllClaudeOAuthAccounts({ local: localMode });
1881
+ const filteredOauthAccounts = wantsClaudeLabel
1882
+ ? oauthAccounts.filter(account => account.label === labelFilter)
1883
+ : oauthAccounts;
1884
+
1885
+ if (filteredOauthAccounts.length) {
1886
+ const rawResults = await Promise.all(
1887
+ filteredOauthAccounts.map(account => fetchClaudeOAuthUsageForAccount(account))
1888
+ );
1889
+ claudeResults = deduplicateClaudeResultsByUsage(rawResults);
1890
+ } else {
1891
+ const claudeAccounts = loadClaudeAccounts();
1892
+ const filteredClaudeAccounts = wantsClaudeLabel
1893
+ ? claudeAccounts.filter(account => account.label === labelFilter)
1894
+ : claudeAccounts;
1895
+
1896
+ if (filteredClaudeAccounts.length) {
1897
+ const rawResults = await Promise.all(
1898
+ filteredClaudeAccounts.map(account => fetchClaudeUsageForCredentials(account))
1899
+ );
1900
+ claudeResults = deduplicateClaudeResultsByUsage(rawResults);
1901
+ } else if (wantsClaudeLabel) {
1902
+ const availableLabels = new Set([
1903
+ ...oauthAccounts.map(account => account.label),
1904
+ ...claudeAccounts.map(account => account.label),
1905
+ ]);
1906
+ const labelList = Array.from(availableLabels);
1907
+ if (flags.json) {
1908
+ console.log(JSON.stringify({
1909
+ success: false,
1910
+ error: `Claude account "${labelFilter}" not found`,
1911
+ availableLabels: labelList,
1912
+ }, null, 2));
1913
+ } else {
1914
+ console.error(colorize(`Claude account "${labelFilter}" not found.`, RED));
1915
+ if (labelList.length) {
1916
+ console.error(`Available: ${labelList.join(", ")}`);
1917
+ }
1918
+ }
1919
+ process.exit(1);
1920
+ } else {
1921
+ const legacyResult = await fetchClaudeUsage();
1922
+ if (legacyResult.success || legacyResult.usage) {
1923
+ claudeResults = [legacyResult];
1924
+ }
1925
+ }
1926
+ }
1927
+ }
1928
+
1929
+ // Check if we have anything to show
1930
+ const hasCodexResults = results.length > 0;
1931
+ const hasClaudeResults = claudeResults && claudeResults.length > 0;
1932
+
1933
+ if (!hasCodexResults && !hasClaudeResults) {
1934
+ if (flags.json) {
1935
+ console.log(JSON.stringify({
1936
+ success: false,
1937
+ error: "No accounts found",
1938
+ }, null, 2));
1939
+ } else {
1940
+ console.error(colorize("No accounts found.", RED));
1941
+ const codexMessage = `Run '${PRIMARY_CMD} codex add' to add a Codex account.`;
1942
+ const claudeMessage = `Run '${PRIMARY_CMD} claude add' to add a Claude account.`;
1943
+ if (scope === "codex") {
1944
+ console.error(`\n${codexMessage}`);
1945
+ } else if (scope === "claude") {
1946
+ console.error(`\n${claudeMessage}`);
1947
+ } else {
1948
+ console.error(`\n${codexMessage}`);
1949
+ console.error(claudeMessage);
1950
+ }
1951
+ }
1952
+ process.exit(1);
1953
+ }
1954
+
1955
+ if (flags.json) {
1956
+ const openaiOutput = results.map(({ account, usage }) => {
1957
+ const profile = extractProfile(account.access);
1958
+ return {
1959
+ label: account.label,
1960
+ email: profile.email,
1961
+ accountId: account.accountId,
1962
+ planType: profile.planType,
1963
+ usage,
1964
+ source: account.source,
1965
+ };
1966
+ });
1967
+ const codexDivergenceInfo = codexDivergence
1968
+ ? {
1969
+ activeLabel: codexDivergence.activeLabel ?? null,
1970
+ activeAccountId: codexDivergence.activeAccount?.accountId ?? null,
1971
+ activeStorePath: codexDivergence.activeStorePath,
1972
+ cliAccountId: codexDivergence.cliAccountId ?? null,
1973
+ cliLabel: codexDivergence.cliLabel ?? null,
1974
+ diverged: codexDivergence.diverged,
1975
+ migrated: codexDivergence.migrated,
1976
+ }
1977
+ : null;
1978
+ const claudeDivergenceInfo = claudeDivergence
1979
+ ? {
1980
+ activeLabel: claudeDivergence.activeLabel ?? null,
1981
+ activeStorePath: claudeDivergence.activeStorePath,
1982
+ diverged: claudeDivergence.diverged,
1983
+ skipped: claudeDivergence.skipped,
1984
+ skipReason: claudeDivergence.skipReason,
1985
+ stores: claudeDivergence.stores,
1986
+ }
1987
+ : null;
1988
+ const openaiOutputWithDivergence = codexDivergenceInfo
1989
+ ? openaiOutput.map(item => ({ ...item, divergence: codexDivergenceInfo }))
1990
+ : openaiOutput;
1991
+ const claudeOutputWithDivergence = claudeDivergenceInfo
1992
+ ? (claudeResults ?? []).map(item => (
1993
+ item && typeof item === "object"
1994
+ ? { ...item, divergence: claudeDivergenceInfo }
1995
+ : item
1996
+ ))
1997
+ : claudeResults ?? [];
1998
+ // Always output both fields when showing both, or just the relevant one
1999
+ if (showCodex && showClaude) {
2000
+ const payload = {
2001
+ codex: openaiOutputWithDivergence,
2002
+ claude: claudeOutputWithDivergence,
2003
+ };
2004
+ payload.divergence = {
2005
+ codex: codexDivergenceInfo,
2006
+ claude: claudeDivergenceInfo,
2007
+ };
2008
+ console.log(JSON.stringify(payload, null, 2));
2009
+ } else if (showClaude) {
2010
+ console.log(JSON.stringify(claudeOutputWithDivergence, null, 2));
2011
+ } else {
2012
+ console.log(JSON.stringify(openaiOutputWithDivergence, null, 2));
2013
+ }
2014
+ return;
2015
+ }
2016
+
2017
+ if (showCodex && codexDivergence?.diverged) {
2018
+ const activeLabelDisplay = codexDivergence.activeLabel ?? "(none)";
2019
+ const activeIdDisplay = codexDivergence.activeAccount?.accountId ?? "(unknown)";
2020
+ const cliLabelDisplay = codexDivergence.cliLabel ?? "(unknown)";
2021
+ const cliIdDisplay = codexDivergence.cliAccountId ?? "(unknown)";
2022
+ console.error(colorize("Warning: CLI auth diverged from activeLabel", YELLOW));
2023
+ console.error(` Active: ${activeLabelDisplay} (${activeIdDisplay})`);
2024
+ console.error(` CLI: ${cliLabelDisplay} (${cliIdDisplay})`);
2025
+ console.error("");
2026
+ console.error(`Run '${PRIMARY_CMD} codex sync' to push active account to CLI.`);
2027
+ console.error("");
2028
+ }
2029
+
2030
+ if (showClaude && claudeDivergence?.diverged) {
2031
+ const activeLabelDisplay = claudeDivergence.activeLabel ?? "(none)";
2032
+ const divergedStores = claudeDivergence.stores
2033
+ .filter(store => store.considered && store.matches === false)
2034
+ .map(store => store.name);
2035
+ const storeDisplay = divergedStores.length ? divergedStores.join(", ") : "one or more stores";
2036
+ console.error(colorize(`Warning: Claude auth diverged from activeLabel (${activeLabelDisplay})`, YELLOW));
2037
+ console.error(` Diverged stores: ${storeDisplay}`);
2038
+ console.error("");
2039
+ console.error(`Run '${PRIMARY_CMD} claude sync' to push active account to CLI.`);
2040
+ console.error("");
2041
+ } else if (showClaude && claudeDivergence?.skipped && claudeDivergence.skipReason === "active-account-not-oauth" && claudeDivergence.activeLabel) {
2042
+ console.error("Note: Active Claude account has no OAuth tokens; skipping divergence check.");
2043
+ console.error("");
2044
+ }
2045
+
2046
+ for (const { account, usage } of results) {
2047
+ const lines = buildAccountUsageLines(account, usage);
2048
+ const boxLines = drawBox(lines);
2049
+ console.log(boxLines.join("\n"));
2050
+ }
2051
+
2052
+ if (claudeResults) {
2053
+ for (const result of claudeResults) {
2054
+ const lines = buildClaudeUsageLines(result);
2055
+ const boxLines = drawBox(lines);
2056
+ console.log(boxLines.join("\n"));
2057
+ }
2058
+ }
2059
+ }
2060
+