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,1438 @@
1
+ /**
2
+ * Divergence detection, reverse-sync, fresher-store resolution.
3
+ * Also includes handleCodexSync and handleClaudeSync since they're tightly
4
+ * coupled with the divergence/sync logic.
5
+ */
6
+
7
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import {
10
+ MULTI_ACCOUNT_PATHS,
11
+ CLAUDE_CREDENTIALS_PATH,
12
+ CLAUDE_MULTI_ACCOUNT_PATHS,
13
+ PRIMARY_CMD,
14
+ } from "./constants.js";
15
+ import { getOpencodeAuthPath, getCodexCliAuthPath, getPiAuthPath } from "./paths.js";
16
+ import { extractAccountId, extractProfile } from "./jwt.js";
17
+ import {
18
+ isValidAccount,
19
+ findAccountByLabel,
20
+ loadAllAccountsNoDedup,
21
+ readCodexActiveStoreContainer,
22
+ getCodexActiveLabelInfo,
23
+ } from "./codex-accounts.js";
24
+ import {
25
+ normalizeClaudeAccount,
26
+ isValidClaudeAccount,
27
+ loadClaudeAccounts,
28
+ findClaudeAccountByLabel,
29
+ getClaudeLabels,
30
+ readClaudeActiveStoreContainer,
31
+ getClaudeActiveLabelInfo,
32
+ findClaudeSessionKey,
33
+ loadClaudeAccountsFromFile,
34
+ } from "./claude-accounts.js";
35
+ import {
36
+ updateOpencodeAuth,
37
+ updatePiAuth,
38
+ persistOpenAiOAuthTokens,
39
+ ensureFreshToken,
40
+ } from "./codex-tokens.js";
41
+ import {
42
+ updateClaudeCredentials,
43
+ updateOpencodeClaudeAuth,
44
+ updatePiClaudeAuth,
45
+ persistClaudeOAuthTokens,
46
+ ensureFreshClaudeOAuthToken,
47
+ refreshClaudeToken,
48
+ normalizeClaudeOauthEntryTokens,
49
+ updateClaudeOauthEntry,
50
+ } from "./claude-tokens.js";
51
+ import { isOauthTokenMatch, normalizeEntryTokens, OPENAI_TOKEN_FIELDS } from "./token-match.js";
52
+ import { readMultiAccountContainer, writeMultiAccountContainer, mapContainerAccounts } from "./container.js";
53
+ import { writeFileAtomic } from "./fs.js";
54
+ import { shortenPath, drawBox, formatExpiryStatus } from "./display.js";
55
+ import { GREEN, YELLOW, RED, colorize } from "./color.js";
56
+ import { promptConfirm, promptInput } from "./prompts.js";
57
+
58
+ // Internal helper
59
+ function normalizeOpenAiOauthEntryTokens(entry) {
60
+ return normalizeEntryTokens(entry, OPENAI_TOKEN_FIELDS);
61
+ }
62
+
63
+ export function readCodexCliAuth() {
64
+ const path = getCodexCliAuthPath();
65
+ if (!existsSync(path)) {
66
+ return {
67
+ path,
68
+ exists: false,
69
+ parsed: null,
70
+ tokens: null,
71
+ accountId: null,
72
+ trackedLabel: null,
73
+ };
74
+ }
75
+
76
+ try {
77
+ const raw = readFileSync(path, "utf-8");
78
+ const parsed = JSON.parse(raw);
79
+ const tokens = parsed?.tokens && typeof parsed.tokens === "object" && !Array.isArray(parsed.tokens)
80
+ ? parsed.tokens
81
+ : null;
82
+ const accountId = resolveCodexCliAccountId(tokens);
83
+ const trackedLabel = typeof parsed?.codex_quota_label === "string" ? parsed.codex_quota_label : null;
84
+ return {
85
+ path,
86
+ exists: true,
87
+ parsed,
88
+ tokens,
89
+ accountId,
90
+ trackedLabel,
91
+ };
92
+ } catch (err) {
93
+ return {
94
+ path,
95
+ exists: true,
96
+ parsed: null,
97
+ tokens: null,
98
+ accountId: null,
99
+ trackedLabel: null,
100
+ error: err?.message ?? String(err),
101
+ };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Resolve a Codex CLI accountId from the tokens object.
107
+ * Prefers tokens.account_id and falls back to decoding the access token.
108
+ * @param {Record<string, unknown> | null} tokens
109
+ * @returns {string | null}
110
+ */
111
+ export function resolveCodexCliAccountId(tokens) {
112
+ if (!tokens) return null;
113
+ const direct = tokens.account_id ?? tokens.accountId ?? null;
114
+ if (typeof direct === "string" && direct) {
115
+ return direct;
116
+ }
117
+ const accessToken = tokens.access_token ?? tokens.accessToken ?? null;
118
+ if (typeof accessToken === "string" && accessToken) {
119
+ return extractAccountId(accessToken);
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Normalize a Codex account entry from a multi-account file.
126
+ * @param {unknown} entry - Raw account entry
127
+ * @param {string} source - Source path
128
+ * @returns {{ label: string, accountId: string, access: string, refresh: string, expires?: number, idToken?: string, source: string } | null}
129
+ */
130
+ export function normalizeCodexAccountEntry(entry, source) {
131
+ if (!entry || typeof entry !== "object") return null;
132
+ const label = entry.label ?? null;
133
+ const accountId = entry.accountId ?? entry.account_id ?? null;
134
+ const access = entry.access ?? entry.access_token ?? null;
135
+ const refresh = entry.refresh ?? entry.refresh_token ?? null;
136
+ const expires = entry.expires ?? entry.expires_at ?? null;
137
+ const idToken = entry.idToken ?? entry.id_token ?? null;
138
+ const normalized = {
139
+ ...entry,
140
+ label,
141
+ accountId,
142
+ access,
143
+ refresh,
144
+ expires,
145
+ idToken,
146
+ source,
147
+ };
148
+ return isValidAccount(normalized) ? normalized : null;
149
+ }
150
+
151
+ /**
152
+ * Find a Codex account by label using no-dedup file-only resolution.
153
+ * This avoids email-based deduplication dropping valid labels.
154
+ * @param {string} label
155
+ * @returns {{ label: string, accountId: string, access: string, refresh: string, expires?: number, idToken?: string, source: string } | null}
156
+ */
157
+ export function findCodexAccountByLabelInFiles(label) {
158
+ for (const path of MULTI_ACCOUNT_PATHS) {
159
+ if (!existsSync(path)) continue;
160
+ const container = readMultiAccountContainer(path);
161
+ if (container.rootType === "invalid") continue;
162
+ for (const entry of container.accounts) {
163
+ const normalized = normalizeCodexAccountEntry(entry, path);
164
+ if (normalized?.label === label) {
165
+ return normalized;
166
+ }
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * Find a Codex account by accountId using file-only resolution.
174
+ * @param {string | null} accountId
175
+ * @returns {{ label: string, accountId: string, source: string } | null}
176
+ */
177
+ export function findCodexAccountByAccountIdInFiles(accountId) {
178
+ if (!accountId) return null;
179
+ for (const path of MULTI_ACCOUNT_PATHS) {
180
+ if (!existsSync(path)) continue;
181
+ const container = readMultiAccountContainer(path);
182
+ if (container.rootType === "invalid") continue;
183
+ for (const entry of container.accounts) {
184
+ if (!entry || typeof entry !== "object") continue;
185
+ const entryAccountId = entry.accountId ?? entry.account_id ?? null;
186
+ if (entryAccountId === accountId && typeof entry.label === "string") {
187
+ return { label: entry.label, accountId, source: path };
188
+ }
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Check whether we have any Codex multi-account file available.
196
+ * @returns {boolean}
197
+ */
198
+ export function hasCodexMultiAccountStore() {
199
+ return MULTI_ACCOUNT_PATHS.some(path => existsSync(path));
200
+ }
201
+
202
+ /**
203
+ * Update Codex activeLabel in the source-of-truth container.
204
+ * Active label is stored only in the first existing multi-account file.
205
+ * If no multi-account file exists, creates one at the default path.
206
+ * @param {string | null} activeLabel
207
+ * @returns {{ updated: boolean, path: string | null, created?: boolean }}
208
+ */
209
+ export function setCodexActiveLabel(activeLabel) {
210
+ const created = !hasCodexMultiAccountStore();
211
+ const { path, container } = readCodexActiveStoreContainer();
212
+ writeMultiAccountContainer(path, container, container.accounts, { activeLabel }, { mode: 0o600 });
213
+ return { updated: true, path, created };
214
+ }
215
+
216
+ /**
217
+ * Update Claude activeLabel in the source-of-truth container.
218
+ * @param {string | null} activeLabel
219
+ * @returns {{ updated: boolean, path: string | null, skipped?: boolean }}
220
+ */
221
+ export function setClaudeActiveLabel(activeLabel) {
222
+ if (!CLAUDE_MULTI_ACCOUNT_PATHS.some(path => existsSync(path))) {
223
+ return { updated: false, path: null, skipped: true };
224
+ }
225
+ const { path, container } = readClaudeActiveStoreContainer();
226
+ writeMultiAccountContainer(path, container, container.accounts, { activeLabel }, { mode: 0o600 });
227
+ return { updated: true, path };
228
+ }
229
+
230
+ /**
231
+ * Clear codex_quota_label when it matches the removed account and accountId guard passes.
232
+ * @param {{ label: string, accountId: string }} account
233
+ * @returns {{ updated: boolean, path: string | null, skipped?: boolean, reason?: string }}
234
+ */
235
+ export function clearCodexQuotaLabelForRemovedAccount(account) {
236
+ const cliAuth = readCodexCliAuth();
237
+ if (!cliAuth.exists || !cliAuth.parsed) {
238
+ return { updated: false, path: null, skipped: true, reason: "auth-missing" };
239
+ }
240
+ if (!cliAuth.trackedLabel || cliAuth.trackedLabel !== account.label) {
241
+ return { updated: false, path: cliAuth.path, skipped: true, reason: "label-mismatch" };
242
+ }
243
+ if (!cliAuth.accountId || cliAuth.accountId !== account.accountId) {
244
+ return { updated: false, path: cliAuth.path, skipped: true, reason: "account-id-mismatch" };
245
+ }
246
+ const updatedPayload = { ...cliAuth.parsed };
247
+ delete updatedPayload.codex_quota_label;
248
+ writeFileAtomic(cliAuth.path, JSON.stringify(updatedPayload, null, 2) + "\n", { mode: 0o600 });
249
+ return { updated: true, path: cliAuth.path };
250
+ }
251
+
252
+ /**
253
+ * Guarded migration: promote codex_quota_label to activeLabel when accountId matches.
254
+ * @param {{ path: string, container: ReturnType<typeof readMultiAccountContainer> }} activeStore
255
+ * @param {ReturnType<typeof readCodexCliAuth>} cliAuth
256
+ * @returns {{ migrated: boolean, activeLabel: string | null }}
257
+ */
258
+ export function maybeMigrateCodexQuotaLabelToActiveLabel(activeStore, cliAuth) {
259
+ const currentActiveLabel = activeStore.container.activeLabel ?? null;
260
+ if (currentActiveLabel) {
261
+ return { migrated: false, activeLabel: currentActiveLabel };
262
+ }
263
+ const trackedLabel = cliAuth.trackedLabel ?? null;
264
+ const cliAccountId = cliAuth.accountId ?? null;
265
+ if (!trackedLabel || !cliAccountId) {
266
+ return { migrated: false, activeLabel: null };
267
+ }
268
+ // Search all sources (env, files, codex-cli auth) not just multi-account files
269
+ const trackedAccount = findAccountByLabel(trackedLabel);
270
+ if (!trackedAccount) {
271
+ return { migrated: false, activeLabel: null };
272
+ }
273
+ if (trackedAccount.accountId !== cliAccountId) {
274
+ return { migrated: false, activeLabel: null };
275
+ }
276
+ writeMultiAccountContainer(
277
+ activeStore.path,
278
+ activeStore.container,
279
+ activeStore.container.accounts,
280
+ { activeLabel: trackedLabel },
281
+ { mode: 0o600 },
282
+ );
283
+ return { migrated: true, activeLabel: trackedLabel };
284
+ }
285
+
286
+ /**
287
+ * Detect whether Codex CLI auth diverged from activeLabel.
288
+ * @param {{ allowMigration?: boolean }} [options]
289
+ * @returns {{
290
+ * activeLabel: string | null,
291
+ * activeAccount: ReturnType<typeof findCodexAccountByLabelInFiles> | null,
292
+ * activeStorePath: string,
293
+ * cliAccountId: string | null,
294
+ * cliLabel: string | null,
295
+ * diverged: boolean,
296
+ * migrated: boolean,
297
+ * }}
298
+ */
299
+ export function detectCodexDivergence(options = {}) {
300
+ const allowMigration = options.allowMigration !== false;
301
+ const activeStore = readCodexActiveStoreContainer();
302
+ const cliAuth = readCodexCliAuth();
303
+ const migration = allowMigration
304
+ ? maybeMigrateCodexQuotaLabelToActiveLabel(activeStore, cliAuth)
305
+ : { migrated: false, activeLabel: activeStore.container.activeLabel ?? null };
306
+ if (!allowMigration && !migration.activeLabel) {
307
+ const trackedLabel = cliAuth.trackedLabel ?? null;
308
+ const cliAccountId = cliAuth.accountId ?? null;
309
+ if (trackedLabel && cliAccountId) {
310
+ // Search all sources (env, files, codex-cli auth) not just multi-account files
311
+ const trackedAccount = findAccountByLabel(trackedLabel);
312
+ if (trackedAccount && trackedAccount.accountId === cliAccountId) {
313
+ migration.activeLabel = trackedLabel;
314
+ }
315
+ }
316
+ }
317
+ const activeLabel = migration.activeLabel ?? activeStore.container.activeLabel ?? null;
318
+ // Search all sources (env, files, codex-cli auth) not just multi-account files
319
+ const activeAccount = activeLabel ? findAccountByLabel(activeLabel) : null;
320
+ const activeAccountId = activeAccount?.accountId ?? null;
321
+ const cliAccountId = cliAuth.accountId ?? null;
322
+ const cliMatch = findCodexAccountByAccountIdInFiles(cliAccountId);
323
+ const cliLabel = cliMatch?.label ?? null;
324
+ const diverged = Boolean(activeAccountId && cliAccountId && activeAccountId !== cliAccountId);
325
+ return {
326
+ activeLabel,
327
+ activeAccount,
328
+ activeStorePath: activeStore.path,
329
+ cliAccountId,
330
+ cliLabel,
331
+ diverged,
332
+ migrated: migration.migrated,
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Find the active Claude account in the source-of-truth file.
338
+ * @returns {{ activeLabel: string | null, account: ReturnType<typeof normalizeClaudeAccount> | null, path: string }}
339
+ */
340
+ export function getActiveClaudeAccountFromStore() {
341
+ const { path, container } = readClaudeActiveStoreContainer();
342
+ const activeLabel = container.activeLabel ?? null;
343
+ if (!activeLabel) {
344
+ return { activeLabel: null, account: null, path };
345
+ }
346
+ if (container.rootType === "invalid") {
347
+ return { activeLabel, account: null, path };
348
+ }
349
+ for (const entry of container.accounts) {
350
+ if (!entry || typeof entry !== "object") continue;
351
+ if (entry.label !== activeLabel) continue;
352
+ const normalized = normalizeClaudeAccount(entry, path);
353
+ if (normalized && isValidClaudeAccount(normalized)) {
354
+ return { activeLabel, account: normalized, path };
355
+ }
356
+ }
357
+ return { activeLabel, account: null, path };
358
+ }
359
+
360
+ /**
361
+ * Read Claude OAuth tokens from Claude Code credentials.
362
+ * @returns {{ name: string, path: string, exists: boolean, tokens: ReturnType<typeof normalizeClaudeOauthEntryTokens> | null }}
363
+ */
364
+ export function readClaudeCodeOauthStore() {
365
+ const path = process.env.CLAUDE_CREDENTIALS_PATH || CLAUDE_CREDENTIALS_PATH;
366
+ if (!existsSync(path)) {
367
+ return { name: "claude-code", path, exists: false, tokens: null };
368
+ }
369
+ try {
370
+ const raw = readFileSync(path, "utf-8");
371
+ const parsed = JSON.parse(raw);
372
+ const oauth = parsed?.claudeAiOauth ?? parsed?.claude_ai_oauth ?? {};
373
+ return { name: "claude-code", path, exists: true, tokens: normalizeClaudeOauthEntryTokens(oauth) };
374
+ } catch {
375
+ return { name: "claude-code", path, exists: true, tokens: null };
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Read Claude OAuth tokens from OpenCode auth.json.
381
+ * @returns {{ name: string, path: string, exists: boolean, tokens: ReturnType<typeof normalizeClaudeOauthEntryTokens> | null }}
382
+ */
383
+ export function readOpencodeClaudeOauthStore() {
384
+ const path = getOpencodeAuthPath();
385
+ if (!existsSync(path)) {
386
+ return { name: "opencode", path, exists: false, tokens: null };
387
+ }
388
+ try {
389
+ const raw = readFileSync(path, "utf-8");
390
+ const parsed = JSON.parse(raw);
391
+ return { name: "opencode", path, exists: true, tokens: normalizeClaudeOauthEntryTokens(parsed?.anthropic ?? {}) };
392
+ } catch {
393
+ return { name: "opencode", path, exists: true, tokens: null };
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Read Claude OAuth tokens from pi auth.json.
399
+ * @returns {{ name: string, path: string, exists: boolean, tokens: ReturnType<typeof normalizeClaudeOauthEntryTokens> | null }}
400
+ */
401
+ export function readPiClaudeOauthStore() {
402
+ const path = getPiAuthPath();
403
+ if (!existsSync(path)) {
404
+ return { name: "pi", path, exists: false, tokens: null };
405
+ }
406
+ try {
407
+ const raw = readFileSync(path, "utf-8");
408
+ const parsed = JSON.parse(raw);
409
+ return { name: "pi", path, exists: true, tokens: normalizeClaudeOauthEntryTokens(parsed?.anthropic ?? {}) };
410
+ } catch {
411
+ return { name: "pi", path, exists: true, tokens: null };
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Compare Claude OAuth tokens, preferring refresh-token matching.
417
+ * @param {{ oauthToken?: string | null, oauthRefreshToken?: string | null }} activeAccount
418
+ * @param {ReturnType<typeof normalizeClaudeOauthEntryTokens> | null} storeTokens
419
+ * @returns {{ considered: boolean, matches: boolean | null, method: "refresh" | "access" | null }}
420
+ */
421
+ export function compareClaudeOauthTokens(activeAccount, storeTokens) {
422
+ if (!storeTokens) {
423
+ return { considered: false, matches: null, method: null };
424
+ }
425
+ const activeRefresh = activeAccount.oauthRefreshToken ?? null;
426
+ const storeRefresh = storeTokens.refresh ?? null;
427
+ if (activeRefresh && storeRefresh) {
428
+ return {
429
+ considered: true,
430
+ matches: activeRefresh === storeRefresh,
431
+ method: "refresh",
432
+ };
433
+ }
434
+ const activeAccess = activeAccount.oauthToken ?? null;
435
+ const storeAccess = storeTokens.access ?? null;
436
+ if (activeAccess && storeAccess) {
437
+ return {
438
+ considered: true,
439
+ matches: activeAccess === storeAccess,
440
+ method: "access",
441
+ };
442
+ }
443
+ return { considered: false, matches: null, method: null };
444
+ }
445
+
446
+ /**
447
+ * Detect whether Claude CLI auth stores diverged from activeLabel.
448
+ * Uses token matching and degrades gracefully when OAuth tokens are absent.
449
+ * @returns {{
450
+ * activeLabel: string | null,
451
+ * activeAccount: ReturnType<typeof normalizeClaudeAccount> | null,
452
+ * activeStorePath: string,
453
+ * diverged: boolean,
454
+ * skipped: boolean,
455
+ * skipReason: string | null,
456
+ * stores: Array<{ name: string, path: string, exists: boolean, considered: boolean, matches: boolean | null, method: string | null }>,
457
+ * }}
458
+ */
459
+ export function detectClaudeDivergence() {
460
+ const active = getActiveClaudeAccountFromStore();
461
+ const activeLabel = active.activeLabel ?? null;
462
+ const activeAccount = active.account ?? null;
463
+ if (!activeLabel) {
464
+ return {
465
+ activeLabel: null,
466
+ activeAccount: null,
467
+ activeStorePath: active.path,
468
+ diverged: false,
469
+ skipped: true,
470
+ skipReason: "no-active-label",
471
+ stores: [],
472
+ };
473
+ }
474
+ if (!activeAccount) {
475
+ return {
476
+ activeLabel,
477
+ activeAccount: null,
478
+ activeStorePath: active.path,
479
+ diverged: false,
480
+ skipped: true,
481
+ skipReason: "active-account-missing",
482
+ stores: [],
483
+ };
484
+ }
485
+ if (!activeAccount.oauthToken) {
486
+ return {
487
+ activeLabel,
488
+ activeAccount,
489
+ activeStorePath: active.path,
490
+ diverged: false,
491
+ skipped: true,
492
+ skipReason: "active-account-not-oauth",
493
+ stores: [],
494
+ };
495
+ }
496
+
497
+ const stores = [
498
+ readClaudeCodeOauthStore(),
499
+ readOpencodeClaudeOauthStore(),
500
+ readPiClaudeOauthStore(),
501
+ ].map(store => {
502
+ const comparison = compareClaudeOauthTokens(activeAccount, store.tokens);
503
+ return {
504
+ name: store.name,
505
+ path: store.path,
506
+ exists: store.exists,
507
+ considered: comparison.considered,
508
+ matches: comparison.matches,
509
+ method: comparison.method,
510
+ };
511
+ });
512
+ const diverged = stores.some(store => store.considered && store.matches === false);
513
+ return {
514
+ activeLabel,
515
+ activeAccount,
516
+ activeStorePath: active.path,
517
+ diverged,
518
+ skipped: false,
519
+ skipReason: null,
520
+ stores,
521
+ };
522
+ }
523
+
524
+ export function isLikelyValidClaudeOauthTokens(tokens) {
525
+ if (!tokens?.access) return false;
526
+ if (typeof tokens.expires === "number" && tokens.expires <= Date.now()) return false;
527
+ return true;
528
+ }
529
+
530
+ export function isClaudeOauthTokenEquivalent(storeTokens, account) {
531
+ if (!storeTokens || !account) return false;
532
+ const storeRefresh = storeTokens.refresh ?? null;
533
+ const storeAccess = storeTokens.access ?? null;
534
+ const accountRefresh = account.oauthRefreshToken ?? null;
535
+ const accountAccess = account.oauthToken ?? null;
536
+ if (storeRefresh && accountRefresh) return storeRefresh === accountRefresh;
537
+ if (storeAccess && accountAccess) return storeAccess === accountAccess;
538
+ return false;
539
+ }
540
+
541
+ /**
542
+ * Find Claude OAuth tokens in OpenCode/pi that are not present in managed accounts.
543
+ * @param {Array<ReturnType<typeof normalizeClaudeAccount>>} managedAccounts
544
+ * @returns {Array<{ name: string, path: string, tokens: ReturnType<typeof normalizeClaudeOauthEntryTokens> }>}
545
+ */
546
+ export function findUntrackedClaudeOauthStores(managedAccounts) {
547
+ const trackedAccounts = Array.isArray(managedAccounts) ? managedAccounts : [];
548
+ const stores = [
549
+ readOpencodeClaudeOauthStore(),
550
+ readPiClaudeOauthStore(),
551
+ ];
552
+ const untracked = [];
553
+
554
+ for (const store of stores) {
555
+ if (!store.exists || !store.tokens) continue;
556
+ if (!isLikelyValidClaudeOauthTokens(store.tokens)) continue;
557
+ const matches = trackedAccounts.some(account => isClaudeOauthTokenEquivalent(store.tokens, account));
558
+ if (!matches) {
559
+ untracked.push({
560
+ name: store.name,
561
+ path: store.path,
562
+ tokens: store.tokens,
563
+ });
564
+ }
565
+ }
566
+
567
+ return untracked;
568
+ }
569
+
570
+ export async function maybeImportClaudeOauthStores(options = {}) {
571
+ const json = Boolean(options.json);
572
+ const result = {
573
+ updated: false,
574
+ warnings: [],
575
+ };
576
+ if (json) return result;
577
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return result;
578
+
579
+ const { path: targetPath, container } = readClaudeActiveStoreContainer();
580
+ if (container.rootType === "invalid") {
581
+ result.warnings.push(`Invalid Claude accounts file at ${targetPath}`);
582
+ return result;
583
+ }
584
+ if (container.rootType === "missing") {
585
+ container.accounts = [];
586
+ }
587
+
588
+ let managedAccounts = container.accounts
589
+ .map(entry => normalizeClaudeAccount(entry, targetPath))
590
+ .filter(account => account && isValidClaudeAccount(account));
591
+ const existingLabels = new Set(managedAccounts.map(account => account.label));
592
+ const untrackedStores = findUntrackedClaudeOauthStores(managedAccounts);
593
+
594
+ if (!untrackedStores.length) return result;
595
+
596
+ for (const store of untrackedStores) {
597
+ console.error(
598
+ `Detected Claude OAuth token in ${store.name} (${shortenPath(store.path)}) `
599
+ + `not saved in ${shortenPath(targetPath)}.`
600
+ );
601
+
602
+ if (!managedAccounts.length) {
603
+ console.error("No managed Claude accounts found to merge into.");
604
+ }
605
+ console.error("Choose how to record it:");
606
+ console.error(" [1] Add as new account");
607
+ console.error(" [2] Merge into existing account");
608
+ console.error(" [3] Skip\n");
609
+ const choice = (await promptInput("Enter choice (1, 2, or 3): ")).trim();
610
+
611
+ if (choice === "2" && managedAccounts.length) {
612
+ console.error(`Existing labels: ${managedAccounts.map(a => a.label).join(", ")}`);
613
+ const mergeLabel = (await promptInput("Merge into label: ")).trim();
614
+ if (!mergeLabel || !existingLabels.has(mergeLabel)) {
615
+ console.error(colorize(`Skipping: label "${mergeLabel}" not found.`, YELLOW));
616
+ continue;
617
+ }
618
+ const mapped = mapContainerAccounts(container, (entry) => {
619
+ if (!entry || typeof entry !== "object") return entry;
620
+ if (entry.label !== mergeLabel) return entry;
621
+ return updateClaudeOauthEntry({ ...entry }, {
622
+ accessToken: store.tokens.access,
623
+ refreshToken: store.tokens.refresh,
624
+ expiresAt: store.tokens.expires,
625
+ scopes: store.tokens.scopes,
626
+ });
627
+ });
628
+ if (mapped.updated) {
629
+ container.accounts = mapped.accounts;
630
+ result.updated = true;
631
+ managedAccounts = container.accounts
632
+ .map(entry => normalizeClaudeAccount(entry, targetPath))
633
+ .filter(account => account && isValidClaudeAccount(account));
634
+ console.error(colorize(`Merged OAuth token into "${mergeLabel}".`, GREEN));
635
+ } else {
636
+ console.error(colorize(`No changes applied to "${mergeLabel}".`, YELLOW));
637
+ }
638
+ continue;
639
+ }
640
+
641
+ if (choice === "1" || (choice === "2" && !managedAccounts.length)) {
642
+ const label = (await promptInput("New label: ")).trim();
643
+ if (!label) {
644
+ console.error(colorize("Skipping: label is required.", YELLOW));
645
+ continue;
646
+ }
647
+ if (!/^[a-zA-Z0-9_-]+$/.test(label)) {
648
+ console.error(colorize(`Skipping: invalid label "${label}".`, YELLOW));
649
+ continue;
650
+ }
651
+ if (existingLabels.has(label)) {
652
+ console.error(colorize(`Skipping: label "${label}" already exists.`, YELLOW));
653
+ continue;
654
+ }
655
+ const newAccount = {
656
+ label,
657
+ sessionKey: null,
658
+ oauthToken: store.tokens.access,
659
+ oauthRefreshToken: store.tokens.refresh ?? null,
660
+ oauthExpiresAt: store.tokens.expires ?? null,
661
+ oauthScopes: store.tokens.scopes ?? null,
662
+ cfClearance: null,
663
+ orgId: null,
664
+ };
665
+ container.accounts.push(newAccount);
666
+ managedAccounts.push(normalizeClaudeAccount(newAccount, targetPath));
667
+ existingLabels.add(label);
668
+ result.updated = true;
669
+ console.error(colorize(`Added Claude account "${label}".`, GREEN));
670
+ continue;
671
+ }
672
+
673
+ console.error("Skipping import.");
674
+ }
675
+
676
+ if (result.updated) {
677
+ writeMultiAccountContainer(targetPath, container, container.accounts, {}, { mode: 0o600 });
678
+ }
679
+
680
+ return result;
681
+ }
682
+
683
+ export function readOpencodeOpenAiOauthStore() {
684
+ const path = getOpencodeAuthPath();
685
+ if (!existsSync(path)) {
686
+ return { name: "opencode", path, exists: false, tokens: null };
687
+ }
688
+ try {
689
+ const raw = readFileSync(path, "utf-8");
690
+ const parsed = JSON.parse(raw);
691
+ const openai = parsed?.openai ?? {};
692
+ return { name: "opencode", path, exists: true, tokens: normalizeOpenAiOauthEntryTokens(openai) };
693
+ } catch {
694
+ return { name: "opencode", path, exists: true, tokens: null };
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Read OpenAI OAuth tokens from pi auth.json (openai-codex section).
700
+ * @returns {{ name: string, path: string, exists: boolean, tokens: ReturnType<typeof normalizeOpenAiOauthEntryTokens> | null }}
701
+ */
702
+ export function readPiOpenAiOauthStore() {
703
+ const path = getPiAuthPath();
704
+ if (!existsSync(path)) {
705
+ return { name: "pi", path, exists: false, tokens: null };
706
+ }
707
+ try {
708
+ const raw = readFileSync(path, "utf-8");
709
+ const parsed = JSON.parse(raw);
710
+ const codex = parsed?.["openai-codex"] ?? {};
711
+ return { name: "pi", path, exists: true, tokens: normalizeOpenAiOauthEntryTokens(codex) };
712
+ } catch {
713
+ return { name: "pi", path, exists: true, tokens: null };
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Read OpenAI OAuth tokens from Codex CLI auth.json.
719
+ * @returns {{ name: string, path: string, exists: boolean, tokens: ReturnType<typeof normalizeOpenAiOauthEntryTokens> | null }}
720
+ */
721
+ export function readCodexCliOpenAiOauthStore() {
722
+ const cliAuth = readCodexCliAuth();
723
+ if (!cliAuth.exists || !cliAuth.tokens) {
724
+ return { name: "codex-cli", path: cliAuth.path, exists: cliAuth.exists, tokens: null };
725
+ }
726
+ // Codex CLI uses access_token/refresh_token/expires_at (seconds) format
727
+ const tokens = {
728
+ access: cliAuth.tokens.access_token ?? null,
729
+ refresh: cliAuth.tokens.refresh_token ?? null,
730
+ // Convert seconds to ms for consistency
731
+ expires: cliAuth.tokens.expires_at ? cliAuth.tokens.expires_at * 1000 : null,
732
+ accountId: cliAuth.tokens.account_id ?? cliAuth.tokens.accountId ?? null,
733
+ idToken: cliAuth.tokens.id_token ?? null,
734
+ };
735
+ return { name: "codex-cli", path: cliAuth.path, exists: true, tokens };
736
+ }
737
+
738
+ /**
739
+ * Find the CLI store with the freshest OpenAI OAuth token for the active account.
740
+ * Matches by refresh token; returns the store with the newest expires (or newest access if expires unavailable).
741
+ * @param {{ refresh: string, expires?: number, access?: string, accountId?: string }} activeAccount
742
+ * @returns {{ fresher: boolean, store: { name: string, path: string, tokens: { access: string, refresh: string, expires: number | null, accountId: string | null, idToken: string | null } } | null }}
743
+ */
744
+ export function findFresherOpenAiOAuthStore(activeAccount) {
745
+ const activeRefresh = activeAccount.refresh ?? null;
746
+ const activeExpires = activeAccount.expires ?? 0;
747
+ const activeAccess = activeAccount.access ?? null;
748
+
749
+ if (!activeRefresh) {
750
+ return { fresher: false, store: null };
751
+ }
752
+
753
+ const stores = [
754
+ readOpencodeOpenAiOauthStore(),
755
+ readPiOpenAiOauthStore(),
756
+ readCodexCliOpenAiOauthStore(),
757
+ ];
758
+
759
+ let fresherStore = null;
760
+ let fresherExpires = activeExpires;
761
+ let fresherAccess = activeAccess;
762
+
763
+ for (const store of stores) {
764
+ if (!store.exists || !store.tokens) continue;
765
+ const storeRefresh = store.tokens.refresh ?? null;
766
+ const storeExpires = store.tokens.expires ?? 0;
767
+ const storeAccess = store.tokens.access ?? null;
768
+
769
+ // Must match refresh token
770
+ if (storeRefresh !== activeRefresh) continue;
771
+
772
+ // Compare by expires first (if both have it)
773
+ if (storeExpires && fresherExpires) {
774
+ if (storeExpires > fresherExpires) {
775
+ fresherStore = store;
776
+ fresherExpires = storeExpires;
777
+ fresherAccess = storeAccess;
778
+ }
779
+ } else if (storeExpires && !fresherExpires) {
780
+ // Store has expires, active doesn't - store is fresher
781
+ fresherStore = store;
782
+ fresherExpires = storeExpires;
783
+ fresherAccess = storeAccess;
784
+ } else if (storeAccess && storeAccess !== fresherAccess) {
785
+ // Neither has expires - fall back to access token difference
786
+ // Can't determine which is fresher without expires, but if different, prefer store
787
+ // (This is a heuristic: if access tokens differ, assume CLI was refreshed)
788
+ fresherStore = store;
789
+ fresherAccess = storeAccess;
790
+ }
791
+ }
792
+
793
+ if (!fresherStore) {
794
+ return { fresher: false, store: null };
795
+ }
796
+
797
+ return {
798
+ fresher: true,
799
+ store: {
800
+ name: fresherStore.name,
801
+ path: fresherStore.path,
802
+ tokens: fresherStore.tokens,
803
+ },
804
+ };
805
+ }
806
+
807
+ /**
808
+ * Find the CLI store with the freshest Claude OAuth token for the active account.
809
+ * Matches by refresh token; returns the store with the newest expires (or newest access if expires unavailable).
810
+ * @param {{ oauthRefreshToken?: string | null, oauthExpiresAt?: number | null, oauthToken?: string | null }} activeAccount
811
+ * @returns {{ fresher: boolean, store: { name: string, path: string, tokens: { access: string, refresh: string, expires: number | null, scopes: string[] | null } } | null }}
812
+ */
813
+ export function findFresherClaudeOAuthStore(activeAccount) {
814
+ const activeRefresh = activeAccount.oauthRefreshToken ?? null;
815
+ const activeExpires = activeAccount.oauthExpiresAt ?? 0;
816
+ const activeAccess = activeAccount.oauthToken ?? null;
817
+
818
+ if (!activeRefresh) {
819
+ return { fresher: false, store: null };
820
+ }
821
+
822
+ const stores = [
823
+ readClaudeCodeOauthStore(),
824
+ readOpencodeClaudeOauthStore(),
825
+ readPiClaudeOauthStore(),
826
+ ];
827
+
828
+ let fresherStore = null;
829
+ let fresherExpires = activeExpires;
830
+ let fresherAccess = activeAccess;
831
+
832
+ for (const store of stores) {
833
+ if (!store.exists || !store.tokens) continue;
834
+ const storeRefresh = store.tokens.refresh ?? null;
835
+ const storeExpires = store.tokens.expires ?? 0;
836
+ const storeAccess = store.tokens.access ?? null;
837
+
838
+ // Must match refresh token
839
+ if (storeRefresh !== activeRefresh) continue;
840
+
841
+ // Compare by expires first (if both have it)
842
+ if (storeExpires && fresherExpires) {
843
+ if (storeExpires > fresherExpires) {
844
+ fresherStore = store;
845
+ fresherExpires = storeExpires;
846
+ fresherAccess = storeAccess;
847
+ }
848
+ } else if (storeExpires && !fresherExpires) {
849
+ // Store has expires, active doesn't - store is fresher
850
+ fresherStore = store;
851
+ fresherExpires = storeExpires;
852
+ fresherAccess = storeAccess;
853
+ } else if (storeAccess && storeAccess !== fresherAccess) {
854
+ // Neither has expires - fall back to access token difference
855
+ fresherStore = store;
856
+ fresherAccess = storeAccess;
857
+ }
858
+ }
859
+
860
+ if (!fresherStore) {
861
+ return { fresher: false, store: null };
862
+ }
863
+
864
+ return {
865
+ fresher: true,
866
+ store: {
867
+ name: fresherStore.name,
868
+ path: fresherStore.path,
869
+ tokens: fresherStore.tokens,
870
+ },
871
+ };
872
+ }
873
+
874
+ /**
875
+ * Find a consistent Claude OAuth store to recover from when refresh fails.
876
+ * Returns null when CLI stores disagree on token identity.
877
+ * @returns {{ store: { name: string, path: string, tokens: { access: string, refresh: string | null, expires: number | null, scopes: string[] | null } } | null, reason: string | null }}
878
+ */
879
+ export function findClaudeOAuthRecoveryStore() {
880
+ const stores = [
881
+ readClaudeCodeOauthStore(),
882
+ readOpencodeClaudeOauthStore(),
883
+ readPiClaudeOauthStore(),
884
+ ];
885
+ const candidates = stores.filter(store => store.exists && store.tokens && (store.tokens.access || store.tokens.refresh));
886
+ if (!candidates.length) {
887
+ return { store: null, reason: "no-stores" };
888
+ }
889
+ const fingerprints = new Set();
890
+ for (const store of candidates) {
891
+ const token = store.tokens.refresh ?? store.tokens.access ?? null;
892
+ if (token) fingerprints.add(token);
893
+ }
894
+ if (fingerprints.size > 1) {
895
+ return { store: null, reason: "ambiguous" };
896
+ }
897
+ let bestStore = null;
898
+ let bestExpires = 0;
899
+ for (const store of candidates) {
900
+ const expires = typeof store.tokens.expires === "number" ? store.tokens.expires : 0;
901
+ if (!bestStore || expires > bestExpires) {
902
+ bestStore = store;
903
+ bestExpires = expires;
904
+ }
905
+ }
906
+ if (!bestStore) {
907
+ return { store: null, reason: "no-stores" };
908
+ }
909
+ return {
910
+ store: {
911
+ name: bestStore.name,
912
+ path: bestStore.path,
913
+ tokens: bestStore.tokens,
914
+ },
915
+ reason: null,
916
+ };
917
+ }
918
+
919
+ /**
920
+ * Get the currently active account_id from ~/.codex/auth.json
921
+ * @returns {string | null} Active account ID or null if not found
922
+ */
923
+ export function getActiveAccountId() {
924
+ return readCodexCliAuth().accountId ?? null;
925
+ }
926
+
927
+ /**
928
+ * Get detailed info about the currently active account from ~/.codex/auth.json
929
+ * Includes tracked label if set by codex-quota switch command
930
+ * @returns {{ accountId: string | null, trackedLabel: string | null, source: "codex-quota" | "native" | null }}
931
+ */
932
+ export function getActiveAccountInfo() {
933
+ const info = readCodexCliAuth();
934
+ if (!info.exists || !info.accountId) {
935
+ return { accountId: null, trackedLabel: info.trackedLabel ?? null, source: null };
936
+ }
937
+ const source = info.trackedLabel ? "codex-quota" : "native";
938
+ return { accountId: info.accountId, trackedLabel: info.trackedLabel ?? null, source };
939
+ }
940
+
941
+ // formatExpiryStatus and shortenPath are imported from ./display.js
942
+
943
+ /**
944
+ * Handle sync subcommand - bi-directional sync for activeLabel account
945
+ */
946
+ export async function handleCodexSync(args, flags) {
947
+ const dryRun = Boolean(flags.dryRun);
948
+ try {
949
+ const divergence = detectCodexDivergence({ allowMigration: !dryRun });
950
+ const activeLabel = divergence.activeLabel ?? null;
951
+ if (!activeLabel) {
952
+ const message = "No activeLabel set. Run 'codex-quota codex switch <label>' first.";
953
+ if (flags.json) {
954
+ console.log(JSON.stringify({ success: false, error: message }, null, 2));
955
+ } else {
956
+ console.error(colorize(`Error: ${message}`, RED));
957
+ }
958
+ process.exit(1);
959
+ }
960
+
961
+ let account = divergence.activeAccount ?? findCodexAccountByLabelInFiles(activeLabel);
962
+ if (!account) {
963
+ const message = `Active label "${activeLabel}" could not be resolved in multi-account files.`;
964
+ if (flags.json) {
965
+ console.log(JSON.stringify({ success: false, error: message, activeLabel }, null, 2));
966
+ } else {
967
+ console.error(colorize(`Error: ${message}`, RED));
968
+ }
969
+ process.exit(1);
970
+ }
971
+
972
+ const pulledPaths = [];
973
+ const warnings = [];
974
+
975
+ // Reverse-sync: check if any CLI store has a fresher token
976
+ const fresherResult = findFresherOpenAiOAuthStore(account);
977
+ if (fresherResult.fresher && fresherResult.store) {
978
+ const fresherStore = fresherResult.store;
979
+ const fresherTokens = fresherStore.tokens;
980
+ if (!dryRun) {
981
+ // Update the account entry in the multi-account file with the fresher token
982
+ const previousTokens = {
983
+ previousAccessToken: account.access,
984
+ previousRefreshToken: account.refresh,
985
+ };
986
+ const updatedAccount = {
987
+ label: account.label,
988
+ access: fresherTokens.access,
989
+ refresh: fresherTokens.refresh,
990
+ expires: fresherTokens.expires,
991
+ accountId: fresherTokens.accountId ?? account.accountId,
992
+ idToken: fresherTokens.idToken ?? account.idToken,
993
+ source: account.source,
994
+ };
995
+ const persistResult = persistOpenAiOAuthTokens(updatedAccount, previousTokens);
996
+ if (persistResult.updatedPaths.length > 0) {
997
+ pulledPaths.push(fresherStore.path);
998
+ // Update the account reference with the fresher tokens for forward push
999
+ account = { ...account, ...updatedAccount };
1000
+ }
1001
+ if (persistResult.errors.length > 0) {
1002
+ warnings.push(...persistResult.errors);
1003
+ }
1004
+ } else {
1005
+ pulledPaths.push(fresherStore.path);
1006
+ }
1007
+ }
1008
+
1009
+ if (!dryRun) {
1010
+ const refreshAccounts = loadAllAccountsNoDedup();
1011
+ const tokenOk = await ensureFreshToken(account, refreshAccounts);
1012
+ if (!tokenOk) {
1013
+ const message = `Failed to refresh token for "${activeLabel}". Re-authentication may be required.`;
1014
+ if (flags.json) {
1015
+ console.log(JSON.stringify({ success: false, error: message, activeLabel }, null, 2));
1016
+ } else {
1017
+ console.error(colorize(`Error: ${message}`, RED));
1018
+ }
1019
+ process.exit(1);
1020
+ }
1021
+ }
1022
+
1023
+ const profile = extractProfile(account.access);
1024
+ const email = profile.email ?? null;
1025
+ const updatedPaths = [];
1026
+ const skippedPaths = [];
1027
+
1028
+ const codexAuthPath = getCodexCliAuthPath();
1029
+ if (dryRun) {
1030
+ updatedPaths.push(codexAuthPath);
1031
+ } else {
1032
+ let existingAuth = {};
1033
+ if (existsSync(codexAuthPath)) {
1034
+ try {
1035
+ const raw = readFileSync(codexAuthPath, "utf-8");
1036
+ const parsed = JSON.parse(raw);
1037
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1038
+ existingAuth = parsed;
1039
+ }
1040
+ } catch {
1041
+ existingAuth = {};
1042
+ }
1043
+ }
1044
+ const existingTokens = existingAuth.tokens && typeof existingAuth.tokens === "object" && !Array.isArray(existingAuth.tokens)
1045
+ ? existingAuth.tokens
1046
+ : {};
1047
+ const expiresAt = Math.floor(((account.expires ?? (Date.now() - 1000)) / 1000));
1048
+ const updatedTokens = {
1049
+ ...existingTokens,
1050
+ access_token: account.access,
1051
+ refresh_token: account.refresh,
1052
+ account_id: account.accountId,
1053
+ expires_at: expiresAt,
1054
+ };
1055
+ if (account.idToken) {
1056
+ updatedTokens.id_token = account.idToken;
1057
+ } else if ("id_token" in updatedTokens) {
1058
+ delete updatedTokens.id_token;
1059
+ }
1060
+ const updatedAuth = {
1061
+ ...existingAuth,
1062
+ tokens: updatedTokens,
1063
+ last_refresh: new Date().toISOString(),
1064
+ codex_quota_label: activeLabel,
1065
+ };
1066
+ writeFileAtomic(codexAuthPath, JSON.stringify(updatedAuth, null, 2) + "\n", { mode: 0o600 });
1067
+ updatedPaths.push(codexAuthPath);
1068
+ }
1069
+
1070
+ const opencodePath = getOpencodeAuthPath();
1071
+ if (existsSync(opencodePath)) {
1072
+ if (dryRun) {
1073
+ updatedPaths.push(opencodePath);
1074
+ } else {
1075
+ const result = updateOpencodeAuth(account);
1076
+ if (result.updated) {
1077
+ updatedPaths.push(result.path);
1078
+ } else if (result.error) {
1079
+ warnings.push(result.error);
1080
+ }
1081
+ }
1082
+ } else {
1083
+ skippedPaths.push(opencodePath);
1084
+ }
1085
+
1086
+ const piPath = getPiAuthPath();
1087
+ if (existsSync(piPath)) {
1088
+ if (dryRun) {
1089
+ updatedPaths.push(piPath);
1090
+ } else {
1091
+ const result = updatePiAuth(account);
1092
+ if (result.updated) {
1093
+ updatedPaths.push(result.path);
1094
+ } else if (result.error) {
1095
+ warnings.push(result.error);
1096
+ }
1097
+ }
1098
+ } else {
1099
+ skippedPaths.push(piPath);
1100
+ }
1101
+
1102
+ if (flags.json) {
1103
+ console.log(JSON.stringify({
1104
+ success: true,
1105
+ dryRun,
1106
+ activeLabel,
1107
+ email,
1108
+ accountId: account.accountId,
1109
+ pulled: pulledPaths,
1110
+ updated: updatedPaths,
1111
+ skipped: skippedPaths,
1112
+ warnings,
1113
+ }, null, 2));
1114
+ return;
1115
+ }
1116
+
1117
+ const emailDisplay = email ? ` <${email}>` : "";
1118
+ const lines = [
1119
+ `Syncing active account: ${activeLabel}${emailDisplay}`,
1120
+ "",
1121
+ ];
1122
+ if (dryRun) {
1123
+ lines.push("Dry run: no files were written.");
1124
+ lines.push("");
1125
+ }
1126
+ if (pulledPaths.length) {
1127
+ lines.push("Pulled fresher token from:");
1128
+ for (const path of pulledPaths) {
1129
+ lines.push(` ${shortenPath(path)}`);
1130
+ }
1131
+ lines.push("");
1132
+ }
1133
+ lines.push("Updated:");
1134
+ if (updatedPaths.length) {
1135
+ for (const path of updatedPaths) {
1136
+ lines.push(` ${shortenPath(path)}`);
1137
+ }
1138
+ } else {
1139
+ lines.push(" (none)");
1140
+ }
1141
+ lines.push("");
1142
+ lines.push("Skipped (not found):");
1143
+ if (skippedPaths.length) {
1144
+ for (const path of skippedPaths) {
1145
+ lines.push(` ${shortenPath(path)}`);
1146
+ }
1147
+ } else {
1148
+ lines.push(" (none)");
1149
+ }
1150
+ const boxLines = drawBox(lines);
1151
+ console.log(boxLines.join("\n"));
1152
+ for (const warning of warnings) {
1153
+ console.error(colorize(`Warning: ${warning}`, YELLOW));
1154
+ }
1155
+ } catch (error) {
1156
+ if (flags.json) {
1157
+ console.log(JSON.stringify({
1158
+ success: false,
1159
+ error: error.message,
1160
+ }, null, 2));
1161
+ } else {
1162
+ console.error(colorize(`Error: ${error.message}`, RED));
1163
+ }
1164
+ process.exit(1);
1165
+ }
1166
+ }
1167
+
1168
+ export async function handleClaudeSync(args, flags) {
1169
+ const dryRun = Boolean(flags.dryRun);
1170
+ try {
1171
+ const active = getActiveClaudeAccountFromStore();
1172
+ const activeLabel = active.activeLabel ?? null;
1173
+ if (!activeLabel) {
1174
+ const message = "No activeLabel set. Run 'codex-quota claude switch <label>' first.";
1175
+ if (flags.json) {
1176
+ console.log(JSON.stringify({ success: false, error: message }, null, 2));
1177
+ } else {
1178
+ console.error(colorize(`Error: ${message}`, RED));
1179
+ }
1180
+ process.exit(1);
1181
+ }
1182
+ let account = active.account;
1183
+ if (!account) {
1184
+ const message = `Active label "${activeLabel}" could not be resolved in ~/.claude-accounts.json.`;
1185
+ if (flags.json) {
1186
+ console.log(JSON.stringify({ success: false, error: message, activeLabel }, null, 2));
1187
+ } else {
1188
+ console.error(colorize(`Error: ${message}`, RED));
1189
+ }
1190
+ process.exit(1);
1191
+ }
1192
+ if (!account.oauthToken) {
1193
+ const warning = "Active Claude account has no OAuth tokens; nothing to sync.";
1194
+ if (flags.json) {
1195
+ console.log(JSON.stringify({
1196
+ success: true,
1197
+ dryRun,
1198
+ activeLabel,
1199
+ pulled: [],
1200
+ updated: [],
1201
+ skipped: [],
1202
+ warnings: [warning],
1203
+ }, null, 2));
1204
+ } else {
1205
+ console.error(warning);
1206
+ }
1207
+ return;
1208
+ }
1209
+
1210
+ const pulledPaths = [];
1211
+ const warnings = [];
1212
+
1213
+ // Reverse-sync: check if any CLI store has a fresher token
1214
+ const fresherResult = findFresherClaudeOAuthStore(account);
1215
+ if (fresherResult.fresher && fresherResult.store) {
1216
+ const fresherStore = fresherResult.store;
1217
+ const fresherTokens = fresherStore.tokens;
1218
+ if (!dryRun) {
1219
+ // Update the account entry in the multi-account file with the fresher token
1220
+ const previousTokens = {
1221
+ previousAccessToken: account.oauthToken,
1222
+ previousRefreshToken: account.oauthRefreshToken,
1223
+ };
1224
+ const updatedAccount = {
1225
+ label: account.label,
1226
+ accessToken: fresherTokens.access,
1227
+ refreshToken: fresherTokens.refresh,
1228
+ expiresAt: fresherTokens.expires,
1229
+ scopes: fresherTokens.scopes ?? account.oauthScopes,
1230
+ source: account.source,
1231
+ };
1232
+ const persistResult = persistClaudeOAuthTokens(updatedAccount, previousTokens);
1233
+ if (persistResult.updatedPaths.length > 0) {
1234
+ pulledPaths.push(fresherStore.path);
1235
+ // Update the account reference with the fresher tokens for forward push
1236
+ account = {
1237
+ ...account,
1238
+ oauthToken: fresherTokens.access,
1239
+ oauthRefreshToken: fresherTokens.refresh,
1240
+ oauthExpiresAt: fresherTokens.expires,
1241
+ oauthScopes: fresherTokens.scopes ?? account.oauthScopes,
1242
+ };
1243
+ }
1244
+ if (persistResult.errors.length > 0) {
1245
+ warnings.push(...persistResult.errors);
1246
+ }
1247
+ } else {
1248
+ pulledPaths.push(fresherStore.path);
1249
+ }
1250
+ }
1251
+
1252
+ if (!dryRun) {
1253
+ let tokenOk = await ensureFreshClaudeOAuthToken(account);
1254
+ if (!tokenOk) {
1255
+ const recovery = findClaudeOAuthRecoveryStore();
1256
+ const recoveryStore = recovery.store;
1257
+ const recoveryTokens = recoveryStore?.tokens ?? null;
1258
+ const activeExpires = account.oauthExpiresAt ?? 0;
1259
+ const recoveryExpires = recoveryTokens?.expires ?? 0;
1260
+ const recoveryIsNewer = Boolean(recoveryExpires && (!activeExpires || recoveryExpires > activeExpires));
1261
+ const recoveryHasAccess = Boolean(recoveryTokens?.access);
1262
+ let recovered = false;
1263
+
1264
+ if (recoveryStore && recoveryTokens && recoveryHasAccess && (recoveryIsNewer || !activeExpires)) {
1265
+ const previousTokens = {
1266
+ previousAccessToken: account.oauthToken,
1267
+ previousRefreshToken: account.oauthRefreshToken,
1268
+ };
1269
+ const updatedAccount = {
1270
+ label: account.label,
1271
+ accessToken: recoveryTokens.access,
1272
+ refreshToken: recoveryTokens.refresh,
1273
+ expiresAt: recoveryTokens.expires,
1274
+ scopes: recoveryTokens.scopes ?? account.oauthScopes,
1275
+ source: account.source,
1276
+ };
1277
+ const persistResult = persistClaudeOAuthTokens(updatedAccount, previousTokens);
1278
+ if (persistResult.updatedPaths.length > 0) {
1279
+ pulledPaths.push(recoveryStore.path);
1280
+ account = {
1281
+ ...account,
1282
+ oauthToken: recoveryTokens.access,
1283
+ oauthRefreshToken: recoveryTokens.refresh,
1284
+ oauthExpiresAt: recoveryTokens.expires,
1285
+ oauthScopes: recoveryTokens.scopes ?? account.oauthScopes,
1286
+ };
1287
+ recovered = true;
1288
+ }
1289
+ if (persistResult.errors.length > 0) {
1290
+ warnings.push(...persistResult.errors);
1291
+ }
1292
+ if (recovered) {
1293
+ warnings.push(
1294
+ `Claude OAuth refresh failed; recovered tokens from ${shortenPath(recoveryStore.path)}.`
1295
+ );
1296
+ }
1297
+ }
1298
+
1299
+ tokenOk = recovered;
1300
+ if (!tokenOk) {
1301
+ let detail = "";
1302
+ if (recovery.reason === "ambiguous") {
1303
+ detail = " CLI auth stores disagree; refusing to overwrite.";
1304
+ } else if (recovery.reason === "no-stores") {
1305
+ detail = " No valid CLI auth stores found.";
1306
+ }
1307
+ const message = `Failed to refresh Claude OAuth token for "${activeLabel}".${detail}`;
1308
+ if (flags.json) {
1309
+ console.log(JSON.stringify({ success: false, error: message, activeLabel }, null, 2));
1310
+ } else {
1311
+ console.error(colorize(`Error: ${message}`, RED));
1312
+ }
1313
+ process.exit(1);
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ const updatedPaths = [];
1319
+ const skippedPaths = [];
1320
+
1321
+ const credentialsPath = process.env.CLAUDE_CREDENTIALS_PATH || CLAUDE_CREDENTIALS_PATH;
1322
+ if (dryRun) {
1323
+ updatedPaths.push(credentialsPath);
1324
+ } else {
1325
+ const credentialsUpdate = updateClaudeCredentials(account);
1326
+ if (credentialsUpdate.error) {
1327
+ if (flags.json) {
1328
+ console.log(JSON.stringify({ success: false, error: credentialsUpdate.error }, null, 2));
1329
+ } else {
1330
+ console.error(colorize(`Error: ${credentialsUpdate.error}`, RED));
1331
+ }
1332
+ process.exit(1);
1333
+ }
1334
+ updatedPaths.push(credentialsUpdate.path);
1335
+ }
1336
+
1337
+ const opencodePath = getOpencodeAuthPath();
1338
+ if (existsSync(opencodePath)) {
1339
+ if (dryRun) {
1340
+ updatedPaths.push(opencodePath);
1341
+ } else {
1342
+ const result = updateOpencodeClaudeAuth(account);
1343
+ if (result.updated) {
1344
+ updatedPaths.push(result.path);
1345
+ } else if (result.error) {
1346
+ warnings.push(result.error);
1347
+ }
1348
+ }
1349
+ } else {
1350
+ skippedPaths.push(opencodePath);
1351
+ }
1352
+
1353
+ const piPath = getPiAuthPath();
1354
+ if (existsSync(piPath)) {
1355
+ if (dryRun) {
1356
+ updatedPaths.push(piPath);
1357
+ } else {
1358
+ const result = updatePiClaudeAuth(account);
1359
+ if (result.updated) {
1360
+ updatedPaths.push(result.path);
1361
+ } else if (result.error) {
1362
+ warnings.push(result.error);
1363
+ }
1364
+ }
1365
+ } else {
1366
+ skippedPaths.push(piPath);
1367
+ }
1368
+
1369
+ if (flags.json) {
1370
+ console.log(JSON.stringify({
1371
+ success: true,
1372
+ dryRun,
1373
+ activeLabel,
1374
+ pulled: pulledPaths,
1375
+ updated: updatedPaths,
1376
+ skipped: skippedPaths,
1377
+ warnings,
1378
+ }, null, 2));
1379
+ return;
1380
+ }
1381
+
1382
+ const lines = [
1383
+ `Syncing active account: ${activeLabel}`,
1384
+ "",
1385
+ ];
1386
+ if (dryRun) {
1387
+ lines.push("Dry run: no files were written.");
1388
+ lines.push("");
1389
+ }
1390
+ if (pulledPaths.length) {
1391
+ lines.push("Pulled fresher token from:");
1392
+ for (const path of pulledPaths) {
1393
+ lines.push(` ${shortenPath(path)}`);
1394
+ }
1395
+ lines.push("");
1396
+ }
1397
+ lines.push("Updated:");
1398
+ if (updatedPaths.length) {
1399
+ for (const path of updatedPaths) {
1400
+ lines.push(` ${shortenPath(path)}`);
1401
+ }
1402
+ } else {
1403
+ lines.push(" (none)");
1404
+ }
1405
+ lines.push("");
1406
+ lines.push("Skipped (not found):");
1407
+ if (skippedPaths.length) {
1408
+ for (const path of skippedPaths) {
1409
+ lines.push(` ${shortenPath(path)}`);
1410
+ }
1411
+ } else {
1412
+ lines.push(" (none)");
1413
+ }
1414
+ console.log(drawBox(lines).join("\n"));
1415
+ for (const warning of warnings) {
1416
+ console.error(colorize(`Warning: ${warning}`, YELLOW));
1417
+ }
1418
+ } catch (error) {
1419
+ if (flags.json) {
1420
+ console.log(JSON.stringify({
1421
+ success: false,
1422
+ error: error.message,
1423
+ }, null, 2));
1424
+ } else {
1425
+ console.error(colorize(`Error: ${error.message}`, RED));
1426
+ }
1427
+ process.exit(1);
1428
+ }
1429
+ }
1430
+
1431
+ /**
1432
+ * Handle Claude add subcommand - add a Claude credential interactively
1433
+ * Supports two authentication methods:
1434
+ * - OAuth browser flow (--oauth): Opens browser for authentication
1435
+ * - Manual entry (--manual): Paste sessionKey/token directly
1436
+ * @param {string[]} args - Non-flag arguments (optional label)
1437
+ * @param {{ json: boolean, noBrowser: boolean, oauth: boolean, manual: boolean }} flags - Parsed flags
1438
+ */