Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npx lint-staged
npx lint-staged --no-stash
986 changes: 986 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
preload = ["@opentui/solid/preload"]

[test]
root = "test/tui"

[build]
target = "bun"
31 changes: 30 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default [
ignores: ["dist/**", "coverage/**", "node_modules/**", "winston/**", ".tmp*/**", "vendor/**", "*.cjs", "*.mjs"],
},
{
files: ["index.ts", "lib/**/*.ts"],
files: ["index.ts", "lib/**/*.ts", "runtime/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
Expand Down Expand Up @@ -39,6 +39,35 @@ export default [
"no-duplicate-imports": "error",
},
},
{
files: ["runtime/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.runtime-opentui.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/require-await": "warn",
"no-console": "off",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"no-duplicate-imports": "error",
},
Comment on lines +42 to +69
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd . runtime -e ts -e tsx
python - <<'PY'
from pathlib import Path
eslint = Path("eslint.config.js").read_text()
runtime_tsconfig = Path("tsconfig.runtime-opentui.json").read_text()
print("eslint has runtime ts glob:", '"runtime/**/*.ts"' in eslint)
print("eslint has runtime tsx glob:", '"runtime/**/*.tsx"' in eslint)
print("runtime tsconfig includes tsx:", '"runtime/**/*.tsx"' in runtime_tsconfig)
PY

Repository: ndycode/codex-multi-auth

Length of output: 336


add runtime/**/*.tsx to the eslint override.

the eslint config at lines 42–69 matches only runtime/**/*.ts, but tsconfig.runtime-opentui.json includes runtime/**/*.tsx. no .tsx files exist in runtime/ today, but this config mismatch means any future .tsx entrypoints will skip the async/promise safety rules (no-floating-promises, await-thenable, etc.). keep the config aligned.

suggested fix
   {
-    files: ["runtime/**/*.ts"],
+    files: ["runtime/**/*.ts", "runtime/**/*.tsx"],
     languageOptions: {
       parser: tsparser,
       parserOptions: {
         ecmaVersion: "latest",
         sourceType: "module",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
files: ["runtime/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.runtime-opentui.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/require-await": "warn",
"no-console": "off",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"no-duplicate-imports": "error",
},
{
files: ["runtime/**/*.ts", "runtime/**/*.tsx"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.runtime-opentui.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/require-await": "warn",
"no-console": "off",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"no-duplicate-imports": "error",
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eslint.config.js` around lines 42 - 69, The override currently targets only
"runtime/**/*.ts" and should include TSX so the TypeScript parser and rules
(parser: tsparser, parserOptions.project: "./tsconfig.runtime-opentui.json")
apply to .tsx entrypoints too; update the files array in this override (the
object that defines plugins "@typescript-eslint" and rules like
"@typescript-eslint/no-floating-promises", "@typescript-eslint/await-thenable",
etc.) to include "runtime/**/*.tsx" alongside "runtime/**/*.ts" so future .tsx
files get the same async/promise safety and linting.

},
Comment on lines +42 to +70
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This block duplicates the rules and plugins defined in the base configuration above. Since runtime/**/*.ts was already added to the first block's files array, you can leverage configuration cascading to simplify this block so it only overrides the parserOptions.project.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At eslint.config.js, line 42:

<comment>This block duplicates the rules and plugins defined in the base configuration above. Since `runtime/**/*.ts` was already added to the first block's files array, you can leverage configuration cascading to simplify this block so it only overrides the `parserOptions.project`.</comment>

<file context>
@@ -39,6 +39,35 @@ export default [
       "no-duplicate-imports": "error",
     },
   },
+  {
+    files: ["runtime/**/*.ts"],
+    languageOptions: {
</file context>
Suggested change
{
files: ["runtime/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.runtime-opentui.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/require-await": "warn",
"no-console": "off",
"prefer-const": "error",
"no-var": "error",
"eqeqeq": ["error", "always"],
"no-duplicate-imports": "error",
},
},
{
files: ["runtime/**/*.ts"],
languageOptions: {
parserOptions: {
project: "./tsconfig.runtime-opentui.json",
},
},
},
Fix with Cubic

{
files: ["scripts/**/*.js"],
languageOptions: {
Expand Down
143 changes: 43 additions & 100 deletions lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ import {
isTTY,
type AccountStatus,
} from "./ui/auth-menu.js";
import { confirm } from "./ui/confirm.js";
import { UI_COPY } from "./ui/copy.js";
import {
resolveAuthAccountDetailSelection,
resolveAuthDashboardSelection,
settleAuthConfirmation,
type AuthConfirmationModalViewModel,
type AuthDashboardInteractionResolution,
} from "./codex-manager/auth-ui-controller.js";

/**
* Detect if running in host Desktop/TUI mode where readline prompts don't work.
Expand Down Expand Up @@ -110,23 +118,6 @@ function formatAccountLabel(account: ExistingAccountInfo, index: number): string
return `${num}. Account`;
}

function resolveAccountSourceIndex(account: ExistingAccountInfo): number {
const sourceIndex =
typeof account.sourceIndex === "number" && Number.isFinite(account.sourceIndex)
? Math.max(0, Math.floor(account.sourceIndex))
: undefined;
if (typeof sourceIndex === "number") return sourceIndex;
if (typeof account.index === "number" && Number.isFinite(account.index)) {
return Math.max(0, Math.floor(account.index));
}
return -1;
}

function warnUnresolvableAccountSelection(account: ExistingAccountInfo): void {
const label = account.email?.trim() || account.accountId?.trim() || `index ${account.index + 1}`;
console.log(`Unable to resolve saved account for action: ${label}`);
}

async function promptDeleteAllTypedConfirm(): Promise<boolean> {
const rl = createInterface({ input, output });
try {
Expand All @@ -137,6 +128,30 @@ async function promptDeleteAllTypedConfirm(): Promise<boolean> {
}
}

async function promptAuthConfirmation(modal: AuthConfirmationModalViewModel): Promise<boolean> {
if (modal.confirmStyle === "typed-delete") {
return promptDeleteAllTypedConfirm();
}
return confirm(modal.message);
}

async function resolveAuthInteraction(
resolution: AuthDashboardInteractionResolution,
): Promise<AuthDashboardInteractionResolution> {
if (resolution.type === "detail") {
const action = await showAccountDetails(resolution.detail.account);
return resolveAuthAccountDetailSelection(resolution.detail.account, action);
}
if (resolution.type === "confirm") {
const confirmed = await promptAuthConfirmation(resolution.modal);
if (!confirmed && resolution.modal.cancelMessage) {
console.log(resolution.modal.cancelMessage);
}
return settleAuthConfirmation(resolution.modal, confirmed);
}
return resolution;
}

async function promptLoginModeFallback(existingAccounts: ExistingAccountInfo[]): Promise<LoginMenuResult> {
const rl = createInterface({ input, output });
try {
Expand Down Expand Up @@ -195,95 +210,23 @@ export async function promptLoginMode(
}

while (true) {
const action = await showAuthMenu(existingAccounts, {
let resolution = resolveAuthDashboardSelection(await showAuthMenu(existingAccounts, {
flaggedCount: options.flaggedCount ?? 0,
statusMessage: options.statusMessage,
});
}));

switch (action.type) {
case "add":
return { mode: "add" };
case "forecast":
return { mode: "forecast" };
case "fix":
return { mode: "fix" };
case "settings":
return { mode: "settings" };
case "fresh":
if (!(await promptDeleteAllTypedConfirm())) {
console.log("\nDelete all cancelled.\n");
continue;
}
return { mode: "fresh", deleteAll: true };
case "check":
return { mode: "check" };
case "deep-check":
return { mode: "deep-check" };
case "verify-flagged":
return { mode: "verify-flagged" };
case "select-account": {
const accountAction = await showAccountDetails(action.account);
if (accountAction === "delete") {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", deleteAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
}
if (accountAction === "set-current") {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", switchAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
}
if (accountAction === "refresh") {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", refreshAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
}
if (accountAction === "toggle") {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", toggleAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
}
continue;
}
case "set-current-account": {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", switchAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
}
case "refresh-account": {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", refreshAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
while (true) {
resolution = await resolveAuthInteraction(resolution);
if (resolution.type === "result") {
return resolution.result;
}
case "toggle-account": {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", toggleAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
if (resolution.type === "warning") {
console.log(resolution.message);
break;
}
case "delete-account": {
const index = resolveAccountSourceIndex(action.account);
if (index >= 0) return { mode: "manage", deleteAccountIndex: index };
warnUnresolvableAccountSelection(action.account);
continue;
if (resolution.type === "continue") {
break;
}
case "search":
// Search is handled in showAuthMenu; keep the main loop active.
continue;
case "delete-all":
if (!(await promptDeleteAllTypedConfirm())) {
console.log("\nDelete all cancelled.\n");
continue;
}
return { mode: "fresh", deleteAll: true };
case "cancel":
return { mode: "cancel" };
}
}
}
Expand Down
48 changes: 37 additions & 11 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { copyTextToClipboard, openBrowserUrl } from "./auth/browser.js";
import {
isNonInteractiveMode,
promptAddAnotherAccount,
promptLoginMode,
type LoginMenuResult,
} from "./cli.js";
import {
extractAccountEmail,
Expand Down Expand Up @@ -81,20 +81,21 @@ import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js";
import { getUiRuntimeOptions } from "./ui/runtime.js";
import { select, type MenuItem } from "./ui/select.js";
import {
buildAuthDashboardViewModel,
buildAuthDashboardScreenState,
formatCompactQuotaSnapshot,
formatRateLimitEntry,
getQuotaCacheEntryForAccount,
resolveActiveIndex,
resolveAuthDashboardCommand,
} from "./codex-manager/auth-ui-controller.js";
import { applyUiThemeFromDashboardSettings, configureUnifiedSettings } from "./codex-manager/settings-hub.js";
import { applyUiThemeFromDashboardSettings, configureUnifiedSettings, persistOpenTuiSettingsSave } from "./codex-manager/settings-hub.js";
import {
configureInkUnifiedSettings,
promptInkAuthDashboard,
promptInkRestoreForLogin,
type InkShellTone,
} from "./ui-ink/index.js";
import { promptOpenTuiAuthDashboard } from "../runtime/opentui/prompt.js";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lib/ importing from ../runtime/opentui/ — unconventional dependency direction

import { promptOpenTuiAuthDashboard } from "../runtime/opentui/prompt.js" (line 98) crosses the lib/runtime/ boundary in the wrong direction. runtime/ is supposed to depend on lib/, not vice versa. this creates a circular potential and breaks build isolation — tests that mock lib/codex-manager.ts now transitively pull in the entire @opentui/solid and solid-js dependency chain from runtime/.

consider exposing a promptOpenTuiAuthDashboard adapter inside lib/ui-ink/index.ts (which is already the ui-layer boundary that codex-manager.ts imports from) and keeping the opentui import inside that layer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/codex-manager.ts
Line: 98

Comment:
**`lib/` importing from `../runtime/opentui/`** — unconventional dependency direction

`import { promptOpenTuiAuthDashboard } from "../runtime/opentui/prompt.js"` (line 98) crosses the `lib/``runtime/` boundary in the wrong direction. `runtime/` is supposed to depend on `lib/`, not vice versa. this creates a circular potential and breaks build isolation — tests that mock `lib/codex-manager.ts` now transitively pull in the entire `@opentui/solid` and solid-js dependency chain from `runtime/`.

consider exposing a `promptOpenTuiAuthDashboard` adapter inside `lib/ui-ink/index.ts` (which is already the ui-layer boundary that `codex-manager.ts` imports from) and keeping the opentui import inside that layer.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex


type TokenSuccess = Extract<TokenResult, { type: "success" }>;
type TokenSuccessWithAccount = TokenSuccess & {
Expand Down Expand Up @@ -3557,7 +3558,7 @@ async function clearAccountsAndReset(): Promise<void> {

async function handleManageAction(
storage: AccountStorageV3,
menuResult: Awaited<ReturnType<typeof promptLoginMode>>,
menuResult: LoginMenuResult,
): Promise<void> {
if (typeof menuResult.switchAccountIndex === "number") {
const index = menuResult.switchAccountIndex;
Expand Down Expand Up @@ -3636,6 +3637,9 @@ async function runAuthLogin(): Promise<number> {
let recoveryStatusText = loginStorage.statusText;
let recoveryStatusTone = loginStorage.statusTone;
if (existingStorage && existingStorage.accounts.length > 0) {
if (isNonInteractiveMode()) {
existingStorage = null;
} else {
Comment on lines +3640 to +3642
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

non-interactive guard placement may cause unintended behavior.

lib/codex-manager.ts:3640-3642 sets existingStorage = null in non-interactive mode, which skips the while loop at line 3643. however, this placement inside the if (existingStorage && existingStorage.accounts.length > 0) block means it only triggers when accounts exist.

if the intent is to skip all interactive prompts in non-interactive mode, the guard should be at the top of the flow or the comment should clarify this is intentional:

 		if (existingStorage && existingStorage.accounts.length > 0) {
 			if (isNonInteractiveMode()) {
+				// skip dashboard menu in non-interactive mode; proceed directly to OAuth
 				existingStorage = null;
 			} else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isNonInteractiveMode()) {
existingStorage = null;
} else {
if (isNonInteractiveMode()) {
// skip dashboard menu in non-interactive mode; proceed directly to OAuth
existingStorage = null;
} else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 3640 - 3642, The non-interactive guard
(isNonInteractiveMode()) currently sits inside the conditional that checks
existingStorage && existingStorage.accounts.length > 0, so existingStorage is
only nulled when accounts exist; move the isNonInteractiveMode() check to the
start of the flow (before evaluating existingStorage and before the while loop
that prompts for selection) so non-interactive mode always skips interactive
prompts, or alternatively add a clarifying comment if the current behavior is
intentional; update references around existingStorage, isNonInteractiveMode(),
and the while prompt loop to reflect the new guard placement.

while (true) {
existingStorage = await loadAccounts();
if (!existingStorage || existingStorage.accounts.length === 0) {
Expand Down Expand Up @@ -3672,22 +3676,40 @@ async function runAuthLogin(): Promise<number> {
}
}
const flaggedStorage = await loadFlaggedAccounts();
const dashboardViewModel = buildAuthDashboardViewModel({
const dashboardState = buildAuthDashboardScreenState({
storage: currentStorage,
quotaCache,
displaySettings,
flaggedCount: flaggedStorage.accounts.length,
statusMessage: showFetchStatus ? () => menuQuotaRefreshStatus : undefined,
});

const menuResult = await promptInkAuthDashboard({
const dashboardViewModel = dashboardState.dashboard;
const openTuiDashboardViewModel = recoveryStatusText
? {
...dashboardViewModel,
menuOptions: {
...dashboardViewModel.menuOptions,
statusMessage: recoveryStatusText,
},
}
: dashboardViewModel;

const menuResult = await promptOpenTuiAuthDashboard({
dashboard: openTuiDashboardViewModel,
onSettingsSave: (event) => {
void persistOpenTuiSettingsSave(event).catch((error: unknown) => {
console.warn(
`OpenTUI settings save failed: ${error instanceof Error ? error.message : String(error)}`,
);
});
Comment on lines +3700 to +3704
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

swallowed promise rejection in settings save callback.

lib/codex-manager.ts:3700-3704 uses void cast and .catch() to handle settings save errors with a console.warn. while this prevents unhandled rejections, it also means settings persistence failures are silently downgraded to warnings.

consider surfacing save failures to the user via the statusMessage field so they know their settings weren't persisted:

 				onSettingsSave: (event) => {
-					void persistOpenTuiSettingsSave(event).catch((error: unknown) => {
+					persistOpenTuiSettingsSave(event).catch((error: unknown) => {
 						console.warn(
 							`OpenTUI settings save failed: ${error instanceof Error ? error.message : String(error)}`,
 						);
+						// optionally set a status message visible to user
 					});
 				},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/codex-manager.ts` around lines 3700 - 3704, The current call to
persistOpenTuiSettingsSave(event) swallows failures by only console.warn-ing in
the .catch; instead, update the error handler to surface the failure to the user
by setting the relevant statusMessage (e.g., this.statusMessage or the
manager/state object that holds statusMessage) with a clear, user-facing message
including the error details, and still log the warning for diagnostics; locate
the call to persistOpenTuiSettingsSave(event) and replace the silent .catch
handler so it sets statusMessage to something like "Failed to save OpenTUI
settings: <error message>" (include error instanceof Error ? error.message :
String(error)) while preserving existing logging.

},
}) ?? await promptInkAuthDashboard({
dashboard: dashboardViewModel,
statusTextOverride: recoveryStatusText,
statusToneOverride: recoveryStatusTone,
}) ?? await promptLoginMode(
dashboardViewModel.accounts,
dashboardViewModel.menuOptions,
);
}) ?? (isNonInteractiveMode()
? { mode: "add" as const }
: { mode: "cancel" as const });
recoveryStatusText = undefined;
recoveryStatusTone = undefined;
const command = resolveAuthDashboardCommand(menuResult);
Expand Down Expand Up @@ -3756,6 +3778,7 @@ async function runAuthLogin(): Promise<number> {
break;
}
}
}
}

const refreshedStorage = await loadAccounts();
Expand Down Expand Up @@ -3792,6 +3815,9 @@ async function runAuthLogin(): Promise<number> {
if (!addAnother) break;
forceNewLogin = true;
}
if (isNonInteractiveMode()) {
return 0;
}
continue loginFlow;
}
}
Expand Down
Loading